""" API endpoints for invoice dispute tracking. Handles: - Dispute CRUD operations - Dispute attachments upload/download - Dispute activity logging - Dashboard statistics """ import secrets import hashlib from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, func from sqlalchemy.orm import selectinload from datetime import datetime, date, timedelta from typing import Optional, List 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 ( InvoiceDispute, DisputeLineItem, DisputeAttachment, DisputeActivity, DisputeType, DisputeStatus, DisputePriority ) from models.invoice import Invoice from models.supplier import Supplier from services.dispute_archival_service import DisputeArchivalService def generate_public_hash() -> str: """Generate a secure random hash for public attachment links""" return secrets.token_urlsafe(32) # 43 character URL-safe string router = APIRouter() # Pydantic Schemas class DisputeLineItemInput(BaseModel): invoice_line_item_id: Optional[int] = None product_name: str product_code: Optional[str] = None 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: Optional[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 title: Optional[str] = None description: Optional[str] = None resolution_notes: Optional[str] = None supplier_response: Optional[str] = None supplier_contact_name: Optional[str] = None resolved_amount: Optional[float] = None class DisputeLineItemResponse(BaseModel): id: int product_name: str product_code: Optional[str] quantity_ordered: Optional[float] quantity_received: Optional[float] quantity_difference: Optional[float] unit_price_quoted: Optional[float] unit_price_charged: Optional[float] price_difference: Optional[float] total_charged: float total_expected: Optional[float] notes: Optional[str] class Config: from_attributes = True class DisputeAttachmentResponse(BaseModel): id: int file_name: str file_type: str file_size_bytes: int attachment_type: str description: Optional[str] uploaded_at: str uploaded_by_username: str public_hash: Optional[str] = None public_url: Optional[str] = None class Config: from_attributes = True class DisputeActivityResponse(BaseModel): id: int activity_type: str description: str old_value: Optional[str] = None new_value: Optional[str] = None created_at: str created_by_username: str class Config: from_attributes = True class DisputeResponse(BaseModel): id: int invoice_id: int invoice_number: Optional[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 supplier_contacted_at: Optional[str] supplier_response: Optional[str] supplier_contact_name: Optional[str] resolved_amount: Optional[float] opened_at: str opened_by: str updated_at: str resolved_at: Optional[str] closed_at: Optional[str] tags: Optional[List[str]] line_items: List[DisputeLineItemResponse] attachments: List[DisputeAttachmentResponse] activity_log: List[DisputeActivityResponse] class Config: from_attributes = True # Endpoints @router.get("") async def get_disputes( status: Optional[str] = None, priority: Optional[str] = None, invoice_id: Optional[int] = None, supplier_id: Optional[int] = None, opened_date: Optional[date] = None, limit: int = 50, offset: int = 0, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get disputes with filters""" # Build base query query = select(InvoiceDispute).options( selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier), selectinload(InvoiceDispute.opened_by_user), selectinload(InvoiceDispute.resolved_by_user), selectinload(InvoiceDispute.closed_by_user), selectinload(InvoiceDispute.line_items), selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user), selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user) ).where(InvoiceDispute.kitchen_id == current_user.kitchen_id) # Join with Invoice for supplier filtering if supplier_id: query = query.join(Invoice, InvoiceDispute.invoice_id == Invoice.id).where(Invoice.supplier_id == supplier_id) if status: try: status_enum = DisputeStatus(status) query = query.where(InvoiceDispute.status == status_enum) except ValueError: pass if priority: try: priority_enum = DisputePriority(priority) query = query.where(InvoiceDispute.priority == priority_enum) except ValueError: pass if invoice_id: query = query.where(InvoiceDispute.invoice_id == invoice_id) if opened_date: day_start = datetime.combine(opened_date, datetime.min.time()) day_end = datetime.combine(opened_date + timedelta(days=1), datetime.min.time()) query = query.where(InvoiceDispute.opened_at >= day_start, InvoiceDispute.opened_at < day_end) # Count total (before pagination) count_query = select(func.count()).select_from(InvoiceDispute).where(InvoiceDispute.kitchen_id == current_user.kitchen_id) if supplier_id: count_query = count_query.join(Invoice, InvoiceDispute.invoice_id == Invoice.id).where(Invoice.supplier_id == supplier_id) if status: try: status_enum = DisputeStatus(status) count_query = count_query.where(InvoiceDispute.status == status_enum) except ValueError: pass if priority: try: priority_enum = DisputePriority(priority) count_query = count_query.where(InvoiceDispute.priority == priority_enum) except ValueError: pass if invoice_id: count_query = count_query.where(InvoiceDispute.invoice_id == invoice_id) if opened_date: day_start = datetime.combine(opened_date, datetime.min.time()) day_end = datetime.combine(opened_date + timedelta(days=1), datetime.min.time()) count_query = count_query.where(InvoiceDispute.opened_at >= day_start, InvoiceDispute.opened_at < day_end) total_result = await db.execute(count_query) total = total_result.scalar() # Apply pagination query = query.order_by(InvoiceDispute.opened_at.desc()) query = query.limit(limit).offset(offset) result = await db.execute(query) disputes = result.scalars().all() return { "disputes": [_format_dispute(dispute) for dispute in disputes], "total": total } @router.get("/{dispute_id}") async def get_dispute( dispute_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ) -> DisputeResponse: """Get single dispute by ID""" result = await db.execute( select(InvoiceDispute).options( selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier), selectinload(InvoiceDispute.opened_by_user), selectinload(InvoiceDispute.resolved_by_user), selectinload(InvoiceDispute.closed_by_user), selectinload(InvoiceDispute.line_items), selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user), selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user) ).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") return _format_dispute(dispute) @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""" # Verify invoice belongs to kitchen result = await db.execute( select(Invoice).where( and_( Invoice.id == dispute_input.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") # 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.NEW, 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: qty_diff = None if item_input.quantity_ordered is not None and item_input.quantity_received is not None: qty_diff = Decimal(str(item_input.quantity_ordered)) - Decimal(str(item_input.quantity_received)) price_diff = None if item_input.unit_price_quoted is not None and item_input.unit_price_charged is not None: price_diff = Decimal(str(item_input.unit_price_charged)) - Decimal(str(item_input.unit_price_quoted)) line_item = DisputeLineItem( dispute_id=dispute.id, invoice_line_item_id=item_input.invoice_line_item_id, product_name=item_input.product_name, product_code=item_input.product_code, 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=qty_diff, 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=price_diff, 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) # Re-fetch with all relationships result = await db.execute( select(InvoiceDispute).options( selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier), selectinload(InvoiceDispute.opened_by_user), selectinload(InvoiceDispute.line_items), selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user), selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user) ).where(InvoiceDispute.id == dispute.id) ) dispute = result.scalar_one() return _format_dispute(dispute) @router.patch("/{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.upper() new_status = update_input.status.value.upper() dispute.status = update_input.status # Log status change with specific activity_type activity = DisputeActivity( dispute_id=dispute.id, activity_type="status_change", description=f"Status changed from {old_status} to {new_status}", created_by=current_user.id ) db.add(activity) changes.append(f"Status changed from {old_status} to {new_status}") # 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 # Auto-set closed_at when status changes to closed if update_input.status == DisputeStatus.CLOSED and not dispute.closed_at: dispute.closed_at = datetime.utcnow() dispute.closed_by = current_user.id if update_input.priority and update_input.priority != dispute.priority: old_priority = dispute.priority.value.upper() new_priority = update_input.priority.value.upper() dispute.priority = update_input.priority # Log priority change with specific activity_type activity = DisputeActivity( dispute_id=dispute.id, activity_type="priority_change", description=f"Priority changed from {old_priority} to {new_priority}", created_by=current_user.id ) db.add(activity) changes.append(f"Priority changed from {old_priority} to {new_priority}") if update_input.title and update_input.title != dispute.title: old_title = dispute.title dispute.title = update_input.title activity = DisputeActivity( dispute_id=dispute.id, activity_type="updated", description=f"Title updated from '{old_title}' to '{update_input.title}'", created_by=current_user.id ) db.add(activity) changes.append("Title updated") if update_input.description is not None and update_input.description != dispute.description: dispute.description = update_input.description activity = DisputeActivity( dispute_id=dispute.id, activity_type="updated", description="Description updated", created_by=current_user.id ) db.add(activity) changes.append("Description updated") if update_input.resolution_notes: dispute.resolution_notes = update_input.resolution_notes activity = DisputeActivity( dispute_id=dispute.id, activity_type="note", description=update_input.resolution_notes, created_by=current_user.id ) db.add(activity) changes.append("Resolution notes updated") if update_input.supplier_response: dispute.supplier_response = update_input.supplier_response if not dispute.supplier_contacted_at: dispute.supplier_contacted_at = datetime.utcnow() # Log note with specific activity_type activity = DisputeActivity( dispute_id=dispute.id, activity_type="note", description=update_input.supplier_response, created_by=current_user.id ) db.add(activity) changes.append(update_input.supplier_response) if update_input.supplier_contact_name: dispute.supplier_contact_name = update_input.supplier_contact_name if update_input.resolved_amount is not None: dispute.resolved_amount = Decimal(str(update_input.resolved_amount)) activity = DisputeActivity( dispute_id=dispute.id, activity_type="updated", description=f"Resolved amount set to £{update_input.resolved_amount:.2f}", created_by=current_user.id ) db.add(activity) changes.append(f"Resolved amount set to £{update_input.resolved_amount:.2f}") dispute.updated_at = datetime.utcnow() await db.commit() return {"status": "updated", "changes": changes} @router.delete("/{dispute_id}") async def delete_dispute( dispute_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Delete a dispute (admin only, use carefully)""" 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") # Cascade delete handles attachments, line items, activity await db.delete(dispute) await db.commit() return {"status": "deleted"} @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") # Read file content file_content = await file.read() file_size = len(file_content) # Save file archival_service = DisputeArchivalService(db, current_user.kitchen_id) success, file_path = await archival_service.save_dispute_attachment( dispute, file_content, file.filename or "attachment", file.content_type or "application/octet-stream" ) if not success: raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}") # Generate public hash for shareable link public_hash = generate_public_hash() # Create attachment record attachment = DisputeAttachment( dispute_id=dispute_id, kitchen_id=current_user.kitchen_id, file_name=file.filename or "attachment", file_path=file_path, file_type=file.content_type or "application/octet-stream", file_size_bytes=file_size, attachment_type=attachment_type, description=description, uploaded_by=current_user.id, public_hash=public_hash ) db.add(attachment) # Log activity activity = DisputeActivity( dispute_id=dispute_id, activity_type="attachment_added", description=f"Attachment added: {file.filename} ({attachment_type})", created_by=current_user.id ) db.add(activity) await db.commit() await db.refresh(attachment) # Archive to Nextcloud if enabled success, result = await archival_service.archive_dispute_attachment(attachment) if success: await db.commit() # Update archived status return { "id": attachment.id, "file_name": file.filename, "file_size": file_size, "archived": success, "public_hash": public_hash, "public_url": f"/api/public/attachments/{public_hash}" } @router.get("/{dispute_id}/attachments/{attachment_id}") async def download_dispute_attachment( dispute_id: int, attachment_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Download dispute attachment""" from fastapi.responses import Response result = await db.execute( select(DisputeAttachment).where( and_( DisputeAttachment.id == attachment_id, DisputeAttachment.dispute_id == dispute_id, DisputeAttachment.kitchen_id == current_user.kitchen_id ) ) ) attachment = result.scalar_one_or_none() if not attachment: raise HTTPException(status_code=404, detail="Attachment not found") # Get file content archival_service = DisputeArchivalService(db, current_user.kitchen_id) success, content = await archival_service.get_attachment_content(attachment) if not success: raise HTTPException(status_code=404, detail="File not found") return Response( content=content, media_type=attachment.file_type, headers={"Content-Disposition": f'inline; filename="{attachment.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).options( selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier) ).where( InvoiceDispute.kitchen_id == current_user.kitchen_id ).order_by(InvoiceDispute.opened_at.desc()).limit(5) ) recent_disputes = result.scalars().all() # Count open disputes (all non-resolved statuses) open_count = sum( status_counts.get(status, {}).get("count", 0) for status in ["NEW", "CONTACTED", "AWAITING_CREDIT", "AWAITING_REPLACEMENT"] ) # Total disputed amount total_disputed_amount = sum(s.get("amount", 0) for s in status_counts.values()) return { "status_counts": status_counts, "total_disputes": sum(s.get("count", 0) for s in status_counts.values()), "open_disputes": open_count, "total_disputed_amount": total_disputed_amount, "recent_disputes": [ { "id": d.id, "invoice_id": d.invoice_id, "title": d.title, "status": d.status.value, "disputed_amount": float(d.difference_amount), "opened_at": d.opened_at.isoformat(), "invoice_number": d.invoice.invoice_number if d.invoice else None, "supplier_name": d.invoice.supplier.name if d.invoice and d.invoice.supplier else "Unknown" } for d in recent_disputes ] } @router.get("/stats/daily") async def get_daily_dispute_stats( from_date: Optional[date] = None, to_date: Optional[date] = None, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get daily dispute statistics for a date range (for purchases chart integration). Returns totals split by resolved/unresolved status for color-coding.""" from sqlalchemy import cast, Date, case resolved_statuses = [DisputeStatus.RESOLVED, DisputeStatus.CLOSED] query = select( cast(InvoiceDispute.opened_at, Date).label("date"), func.count(InvoiceDispute.id).label("count"), func.sum(InvoiceDispute.disputed_amount).label("total_disputed"), # Unresolved breakdown func.sum(case( (InvoiceDispute.status.notin_(resolved_statuses), InvoiceDispute.disputed_amount), else_=0 )).label("unresolved_total"), func.count(case( (InvoiceDispute.status.notin_(resolved_statuses), InvoiceDispute.id), )).label("unresolved_count"), # Resolved breakdown func.sum(case( (InvoiceDispute.status.in_(resolved_statuses), InvoiceDispute.disputed_amount), else_=0 )).label("resolved_total"), func.count(case( (InvoiceDispute.status.in_(resolved_statuses), InvoiceDispute.id), )).label("resolved_count"), ).where( InvoiceDispute.kitchen_id == current_user.kitchen_id ) if from_date: query = query.where(cast(InvoiceDispute.opened_at, Date) >= from_date) if to_date: query = query.where(cast(InvoiceDispute.opened_at, Date) <= to_date) query = query.group_by(cast(InvoiceDispute.opened_at, Date)) query = query.order_by(cast(InvoiceDispute.opened_at, Date)) result = await db.execute(query) rows = result.all() return { "daily_stats": { row.date.isoformat(): { "count": row.count, "total_disputed": float(row.total_disputed or 0), "unresolved_count": row.unresolved_count, "unresolved_total": float(row.unresolved_total or 0), "resolved_count": row.resolved_count, "resolved_total": float(row.resolved_total or 0), } for row in rows } } def _format_dispute(dispute: InvoiceDispute) -> DisputeResponse: """Format dispute for API response""" supplier_name = "Unknown" if dispute.invoice and dispute.invoice.supplier: supplier_name = dispute.invoice.supplier.name elif dispute.invoice and dispute.invoice.vendor_name: supplier_name = dispute.invoice.vendor_name return DisputeResponse( id=dispute.id, invoice_id=dispute.invoice_id, invoice_number=dispute.invoice.invoice_number if dispute.invoice else None, supplier_name=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), supplier_contacted_at=dispute.supplier_contacted_at.isoformat() if dispute.supplier_contacted_at else None, supplier_response=dispute.supplier_response, supplier_contact_name=dispute.supplier_contact_name, resolved_amount=float(dispute.resolved_amount) if dispute.resolved_amount else None, opened_at=dispute.opened_at.isoformat(), opened_by=dispute.opened_by_user.name if dispute.opened_by_user else "Unknown", updated_at=dispute.updated_at.isoformat(), resolved_at=dispute.resolved_at.isoformat() if dispute.resolved_at else None, closed_at=dispute.closed_at.isoformat() if dispute.closed_at else None, tags=dispute.tags, line_items=[ DisputeLineItemResponse( id=item.id, product_name=item.product_name, product_code=item.product_code, quantity_ordered=float(item.quantity_ordered) if item.quantity_ordered else None, quantity_received=float(item.quantity_received) if item.quantity_received else None, quantity_difference=float(item.quantity_difference) if item.quantity_difference else None, unit_price_quoted=float(item.unit_price_quoted) if item.unit_price_quoted else None, unit_price_charged=float(item.unit_price_charged) if item.unit_price_charged else None, price_difference=float(item.price_difference) if item.price_difference else None, total_charged=float(item.total_charged), total_expected=float(item.total_expected) if item.total_expected else None, notes=item.notes ) for item in dispute.line_items ], attachments=[ DisputeAttachmentResponse( id=att.id, file_name=att.file_name, file_type=att.file_type, file_size_bytes=att.file_size_bytes, attachment_type=att.attachment_type, description=att.description, uploaded_at=att.uploaded_at.isoformat(), uploaded_by_username=att.uploaded_by_user.name if att.uploaded_by_user else "Unknown", public_hash=att.public_hash, public_url=f"/api/public/attachments/{att.public_hash}" if att.public_hash else None ) for att in dispute.attachments ], activity_log=[ DisputeActivityResponse( id=act.id, activity_type=act.activity_type, description=act.description, old_value=act.old_value, new_value=act.new_value, created_at=act.created_at.isoformat(), created_by_username=act.created_by_user.name if act.created_by_user else "Unknown" ) for act in sorted(dispute.activity_log, key=lambda x: x.created_at) ] ) # ===== Credit Note Linking Endpoints ===== class OpenDisputeResponse(BaseModel): """Simplified dispute info for linking modal""" id: int title: str dispute_type: str status: str disputed_amount: float opened_at: str invoice_number: Optional[str] = None class LinkCreditNoteInput(BaseModel): """Input for linking a credit note to a dispute""" credit_note_invoice_id: int # The invoice ID with document_type='credit_note' resolved_amount: Optional[float] = None # Optional: override the credit note amount resolution_notes: Optional[str] = None # Optional: additional notes @router.get("/supplier/{supplier_id}/open", response_model=List[OpenDisputeResponse]) async def get_open_disputes_for_supplier( supplier_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get all open/unresolved disputes for a specific supplier""" # Open statuses (not resolved or closed) open_statuses = [ DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED, DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT, DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED ] result = await db.execute( select(InvoiceDispute, Invoice.invoice_number) .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) .where( and_( InvoiceDispute.kitchen_id == current_user.kitchen_id, Invoice.supplier_id == supplier_id, InvoiceDispute.status.in_(open_statuses) ) ) .order_by(InvoiceDispute.opened_at.desc()) ) rows = result.all() return [ OpenDisputeResponse( id=dispute.id, title=dispute.title, dispute_type=dispute.dispute_type.value, status=dispute.status.value, disputed_amount=float(dispute.disputed_amount), opened_at=dispute.opened_at.isoformat(), invoice_number=invoice_number ) for dispute, invoice_number in rows ] @router.post("/{dispute_id}/link-credit-note") async def link_credit_note_to_dispute( dispute_id: int, link_input: LinkCreditNoteInput, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Link a credit note invoice to a dispute and mark it as resolved""" # Get the dispute 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") # Get the credit note invoice result = await db.execute( select(Invoice).where( and_( Invoice.id == link_input.credit_note_invoice_id, Invoice.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") # Verify it's a credit note if credit_note.document_type != 'credit_note': raise HTTPException(status_code=400, detail="Invoice is not a credit note") # Determine resolved amount resolved_amount = link_input.resolved_amount if resolved_amount is None and credit_note.total: # Use the credit note total (as positive value) resolved_amount = abs(float(credit_note.total)) # Update the dispute old_status = dispute.status.value dispute.status = DisputeStatus.RESOLVED dispute.resolved_amount = Decimal(str(resolved_amount)) if resolved_amount else None dispute.resolved_by = current_user.id dispute.resolved_at = datetime.utcnow() if link_input.resolution_notes: dispute.resolution_notes = link_input.resolution_notes # Build credit note reference credit_note_ref = credit_note.invoice_number or f"ID#{credit_note.id}" credit_note_date = credit_note.invoice_date.strftime('%d %b %Y') if credit_note.invoice_date else 'unknown date' # Add activity for status change status_activity = DisputeActivity( dispute_id=dispute.id, activity_type="status_change", description=f"Status changed from {old_status} to RESOLVED", old_value=old_status, new_value="RESOLVED", created_by=current_user.id ) db.add(status_activity) # Add activity for credit note link link_activity = DisputeActivity( dispute_id=dispute.id, activity_type="credit_note_linked", description=f"Dispute resolved with credit note #{credit_note_ref} dated {credit_note_date}", new_value=str(credit_note.id), # Store invoice ID for linking created_by=current_user.id ) db.add(link_activity) # Update the credit note to track the linked dispute credit_note.linked_dispute_id = dispute.id await db.commit() return { "success": True, "message": f"Dispute linked to credit note #{credit_note_ref}", "dispute_id": dispute.id, "credit_note_id": credit_note.id, "credit_note_number": credit_note_ref, "resolved_amount": resolved_amount } # LLM FEATURE — see LLM-MANIFEST.md for removal instructions @router.post("/{dispute_id}/draft-email") async def draft_email( dispute_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Draft a supplier dispute email using AI.""" from services.llm_service import draft_dispute_email from models.settings import KitchenSettings # Load dispute with line items result = await db.execute( select(InvoiceDispute) .options(selectinload(InvoiceDispute.line_items)) .where( 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") # Load supplier name via invoice from models.invoice import Invoice from models.supplier import Supplier inv_result = await db.execute( select(Invoice).where(Invoice.id == dispute.invoice_id) ) invoice = inv_result.scalar_one_or_none() supplier_name = "Unknown Supplier" if invoice and invoice.supplier_id: sup_result = await db.execute( select(Supplier.name).where(Supplier.id == invoice.supplier_id) ) sup_row = sup_result.scalar_one_or_none() if sup_row: supplier_name = sup_row # Load kitchen details settings_result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = settings_result.scalar_one_or_none() kitchen_details = { "name": getattr(settings, "kitchen_display_name", "") or "", "address": " ".join(filter(None, [ getattr(settings, "kitchen_address_line1", ""), getattr(settings, "kitchen_address_line2", ""), getattr(settings, "kitchen_city", ""), getattr(settings, "kitchen_postcode", ""), ])), "email": getattr(settings, "kitchen_email", "") or "", "phone": getattr(settings, "kitchen_phone", "") or "", } dispute_data = { "supplier_name": supplier_name, "invoice_number": invoice.invoice_number if invoice else None, "invoice_date": str(invoice.invoice_date) if invoice and invoice.invoice_date else None, "dispute_type": dispute.dispute_type.value if dispute.dispute_type else "price_discrepancy", "title": dispute.title, "description": dispute.description, "disputed_amount": float(dispute.disputed_amount) if dispute.disputed_amount else 0, "line_items": [ { "product_name": li.product_name, "product_code": li.product_code, "quantity_ordered": float(li.quantity_ordered) if li.quantity_ordered else None, "quantity_received": float(li.quantity_received) if li.quantity_received else None, "unit_price_quoted": float(li.unit_price_quoted) if li.unit_price_quoted else None, "unit_price_charged": float(li.unit_price_charged) if li.unit_price_charged else None, "total_charged": float(li.total_charged) if li.total_charged else 0, "total_expected": float(li.total_expected) if li.total_expected else None, } for li in (dispute.line_items or []) ], } llm_result = await draft_dispute_email( db=db, kitchen_id=current_user.kitchen_id, dispute_data=dispute_data, kitchen_details=kitchen_details, ) return { "llm_status": llm_result["status"], "email_subject": llm_result.get("email_subject"), "email_body": llm_result.get("email_body"), "error": llm_result.get("error"), }