NeuroAgent

MySQL Query: Calculate Start Stop Ticket Records

Learn how to calculate durations between start and stop ticket records in MySQL. Complete guide with self-join queries, PHP implementation, and performance optimization tips.

Question

MySQL query to calculate based on start and stop ticket records

I have a MySQL database with ticket records that include a ticket number, event type (start or stop), location, and date. For each ticket number, there are two records representing a start location and an end location.

I need to:

  • Search for all records on a specific date
  • For each “Stop” record, find the associated “Start” record with the same ticket number
  • Perform calculations based on the start and stop information

I’m also questioning whether my database design is optimal, as I currently have separate rows for each ticket’s start and stop events. I’m considering if it would be better to have a single entry that gets updated with the stop data when received.

I’m implementing this solution in PHP.

NeuroAgent

To calculate based on start and stop ticket records in MySQL, you’ll need to join the ticket records with themselves to match start and stop events for the same ticket number. The optimal approach uses a self-join with conditional matching to pair start and stop records.

Contents

Basic Query Solution

The most straightforward approach uses a self-join to match start and stop records:

sql
SELECT 
    stop.ticket_number,
    stop.date AS stop_date,
    stop.location AS stop_location,
    start.date AS start_date,
    start.location AS start_location,
    TIMESTAMPDIFF(HOUR, start.date, stop.date) AS duration_hours,
    TIMESTAMPDIFF(MINUTE, start.date, stop.date) AS duration_minutes
FROM 
    tickets stop
INNER JOIN 
    tickets start ON stop.ticket_number = start.ticket_number 
                  AND stop.event_type = 'Stop'
                  AND start.event_type = 'Start'
WHERE 
    stop.date = '2024-01-15'
    AND start.date = '2024-01-15'

This query joins the tickets table with itself, matching records where:

  • The ticket numbers are the same
  • One record has event_type = 'Stop' and the other has event_type = 'Start'
  • Both records are from the specified date

Database Design Considerations

Your current design with separate rows for start and stop events has both advantages and disadvantages:

Current Design (Separate Rows):

  • Pros:
    • Historical tracking of all events
    • Easy to audit complete ticket lifecycle
    • Simple to add new event types
  • Cons:
    • Requires joins for basic calculations
    • More complex queries
    • Potential for orphaned records (start without stop)

Alternative Design (Single Updated Record):

sql
CREATE TABLE tickets (
    ticket_number INT PRIMARY KEY,
    start_location VARCHAR(100),
    start_date DATETIME,
    stop_location VARCHAR(100),
    stop_date DATETIME,
    duration_hours INT,
    status ENUM('active', 'completed', 'cancelled'),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

When to choose which design:

  • Use separate rows if you need comprehensive audit trails
  • Use single records if performance is critical and audit trails can be handled separately
  • Consider a hybrid approach with both designs

Advanced Query Techniques

Using ROW_NUMBER() for More Complex Matching

For scenarios where you need more sophisticated matching logic:

sql
WITH numbered_tickets AS (
    SELECT 
        ticket_number,
        location,
        date,
        event_type,
        CASE 
            WHEN event_type = 'Start' THEN 1
            WHEN event_type = 'Stop' THEN 2
        END AS event_order,
        ROW_NUMBER() OVER (PARTITION BY ticket_number ORDER BY date, event_type) AS rn
    FROM tickets
    WHERE date = '2024-01-15'
)
SELECT 
    t1.ticket_number,
    t1.location AS start_location,
    t1.date AS start_date,
    t2.location AS stop_location,
    t2.date AS stop_date,
    TIMESTAMPDIFF(HOUR, t1.date, t2.date) AS duration_hours
FROM numbered_tickets t1
JOIN numbered_tickets t2 ON t1.ticket_number = t2.ticket_number 
                       AND t1.event_order = 1 
                       AND t2.event_order = 2
                       AND t1.rn = t2.rn - 1

Handling Missing Records

To handle cases where start or stop records might be missing:

sql
SELECT 
    COALESCE(stop.ticket_number, start.ticket_number) AS ticket_number,
    stop.location AS stop_location,
    stop.date AS stop_date,
    start.location AS start_location,
    start.date AS start_date,
    CASE 
        WHEN stop.ticket_number IS NOT NULL AND start.ticket_number IS NOT NULL 
        THEN TIMESTAMPDIFF(HOUR, start.date, stop.date)
        ELSE NULL
    END AS duration_hours
FROM 
    (SELECT * FROM tickets WHERE event_type = 'Stop' AND date = '2024-01-15') stop
FULL OUTER JOIN 
    (SELECT * FROM tickets WHERE event_type = 'Start' AND date = '2024-01-15') start
    ON stop.ticket_number = start.ticket_number

PHP Implementation

Here’s a complete PHP implementation for your solution:

php
<?php
class TicketCalculator {
    private $pdo;
    
    public function __construct($pdo) {
        $this->pdo = $pdo;
    }
    
    /**
     * Calculate ticket durations for a specific date
     */
    public function calculateDailyDurations($date) {
        $sql = "SELECT 
                    stop.ticket_number,
                    stop.date AS stop_date,
                    stop.location AS stop_location,
                    start.date AS start_date,
                    start.location AS start_location,
                    TIMESTAMPDIFF(HOUR, start.date, stop.date) AS duration_hours,
                    TIMESTAMPDIFF(MINUTE, start.date, stop.date) AS duration_minutes
                FROM tickets stop
                INNER JOIN tickets start 
                    ON stop.ticket_number = start.ticket_number 
                    AND stop.event_type = 'Stop'
                    AND start.event_type = 'Start'
                WHERE 
                    stop.date = :date
                    AND start.date = :date";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['date' => $date]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
    
    /**
     * Generate daily report with statistics
     */
    public function generateDailyReport($date) {
        $tickets = $this->calculateDailyDurations($date);
        
        $totalTickets = count($tickets);
        $totalHours = array_sum(array_column($tickets, 'duration_hours'));
        $averageDuration = $totalTickets > 0 ? $totalHours / $totalTickets : 0;
        
        return [
            'date' => $date,
            'total_tickets' => $totalTickets,
            'total_hours' => $totalHours,
            'average_duration_hours' => round($averageDuration, 2),
            'tickets' => $tickets
        ];
    }
    
    /**
     * Check for missing start or stop records
     */
    public function findIncompleteTickets($date) {
        // Find tickets with stop but no start
        $sql = "SELECT DISTINCT t1.ticket_number, 'missing_start' AS issue
                FROM tickets t1
                LEFT JOIN tickets t2 ON t1.ticket_number = t2.ticket_number 
                                   AND t1.event_type = 'Stop' 
                                   AND t2.event_type = 'Start'
                WHERE t1.event_type = 'Stop' 
                AND t1.date = :date
                AND t2.ticket_number IS NULL
                
                UNION
                
                SELECT DISTINCT t2.ticket_number, 'missing_stop' AS issue
                FROM tickets t2
                LEFT JOIN tickets t1 ON t2.ticket_number = t1.ticket_number 
                                   AND t2.event_type = 'Start' 
                                   AND t1.event_type = 'Stop'
                WHERE t2.event_type = 'Start' 
                AND t2.date = :date
                AND t1.ticket_number IS NULL";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['date' => $date]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

// Usage example
try {
    $pdo = new PDO('mysql:host=localhost;dbname=tickets_db', 'username', 'password');
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    
    $calculator = new TicketCalculator($pdo);
    
    // Generate report for January 15, 2024
    $report = $calculator->generateDailyReport('2024-01-15');
    echo "Daily Report for {$report['date']}:";
    echo "Total Tickets: {$report['total_tickets']}";
    echo "Total Hours: {$report['total_hours']}";
    echo "Average Duration: {$report['average_duration_hours']} hours";
    
    // Check for incomplete tickets
    $incomplete = $calculator->findIncompleteTickets('2024-01-15');
    if (!empty($incomplete)) {
        echo "Incomplete tickets found:";
        print_r($incomplete);
    }
    
} catch (PDOException $e) {
    echo "Database error: " . $e->getMessage();
}
?>

Performance Optimization

Indexing Strategy

For optimal performance, ensure you have proper indexes:

sql
-- Create indexes for the most common query patterns
CREATE INDEX idx_ticket_number ON tickets(ticket_number);
CREATE INDEX idx_event_type ON tickets(event_type);
CREATE INDEX idx_date ON tickets(date);
CREATE INDEX idx_ticket_event_date ON tickets(ticket_number, event_type, date);

-- Composite index for the most common query
CREATE INDEX idx_composite_query ON tickets(date, event_type, ticket_number);

Query Optimization Techniques

  1. Use EXPLAIN to analyze query execution plans:

    sql
    EXPLAIN SELECT stop.ticket_number FROM tickets stop
    INNER JOIN tickets start ON stop.ticket_number = start.ticket_number
    WHERE stop.date = '2024-01-15';
    
  2. Limit results when working with large datasets:

    sql
    -- Add LIMIT for testing, then remove for production
    SELECT ... LIMIT 1000;
    
  3. Consider materialized views for frequently accessed reports:

    sql
    CREATE TABLE daily_ticket_stats (
        report_date DATE PRIMARY KEY,
        total_tickets INT,
        total_hours DECIMAL(10,2),
        generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    

Best Practices

  1. Data Validation: Ensure data integrity at the application level

    php
    // Validate that start date comes before stop date
    if ($startDate >= $stopDate) {
        throw new InvalidArgumentException("Stop date must be after start date");
    }
    
  2. Transaction Management: Use transactions for critical operations

    php
    $pdo->beginTransaction();
    try {
        // Insert both start and stop records
        $pdo->commit();
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
    
  3. Error Handling: Implement comprehensive error handling

    php
    private function validateTicketData($data) {
        $required = ['ticket_number', 'event_type', 'location', 'date'];
        foreach ($required as $field) {
            if (empty($data[$field])) {
                throw new InvalidArgumentException("Missing required field: {$field}");
            }
        }
    }
    
  4. Caching: Cache frequent queries to reduce database load

    php
    // Example using file-based caching
    $cacheFile = "cache/daily_report_{$date}.json";
    if (file_exists($cacheFile) && time() - filemtime($cacheFile) < 3600) {
        return json_decode(file_get_contents($cacheFile), true);
    }
    
  5. Monitoring: Set up monitoring for query performance and data quality

    sql
    -- Monitor query execution times
    SELECT * FROM performance_schema.events_statements_summary_by_digest
    WHERE digest_text LIKE '%calculateDailyDurations%'
    ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
    

Your current database design with separate rows for start and stop events is actually quite common and provides good auditability. The performance impact of the joins is typically minimal unless you’re dealing with millions of records. For most use cases, the provided solution will work efficiently with proper indexing.

Sources

  1. MySQL Join Made Easy For Beginners
  2. MySQL JOIN - GeeksforGeeks
  3. MySQL JOIN types - INNER JOIN, SELF JOIN, CROSS JOIN, FULL OUTER
  4. MySQL 8.0 Reference Manual - SELECT Statement
  5. Database Administrators - How to avoid deadlocks when generating consecutive ticket numbers
  6. Stack Overflow - Making a MySQL query return/stop executing after finding the first match

Conclusion

To effectively calculate based on start and stop ticket records in MySQL:

  1. Use self-joins to match start and stop records by ticket number and event types
  2. Implement proper indexing on ticket_number, event_type, and date columns for optimal performance
  3. Consider your database design - separate rows provide auditability while single records offer simplicity
  4. Use PHP classes to encapsulate business logic and improve code maintainability
  5. Handle edge cases like missing records and data validation
  6. Monitor performance and optimize queries as your dataset grows

The solution provided balances performance with data integrity and provides a solid foundation for your ticket management system. Start with the basic query approach and gradually implement more advanced features as your requirements evolve.