rows for a single task */ public function allForTask(int $taskId): array { $stmt = $this->pdo->prepare( 'SELECT * FROM task_assignments WHERE task_id = ?' ); $stmt->execute([$taskId]); $out = []; foreach ($stmt as $row) { $out[] = self::hydrate($row); } return $out; } /** * All assignments for a given sprint_worker (used by removeWorker to * snapshot rows before the FK cascade wipes them). * * @return list */ public function allForSprintWorker(int $swId): array { $stmt = $this->pdo->prepare( 'SELECT * FROM task_assignments WHERE sprint_worker_id = ?' ); $stmt->execute([$swId]); $out = []; foreach ($stmt as $row) { $out[] = self::hydrate($row); } return $out; } public function find(int $taskId, int $swId): ?TaskAssignment { $stmt = $this->pdo->prepare( 'SELECT * FROM task_assignments WHERE task_id = ? AND sprint_worker_id = ?' ); $stmt->execute([$taskId, $swId]); $row = $stmt->fetch(); return is_array($row) ? self::hydrate($row) : null; } /** * Full grid for a sprint: [task_id][sw_id] => days. Tasks with no rows * are simply absent from the outer map. * * @return array> */ public function gridForSprint(int $sprintId): array { $stmt = $this->pdo->prepare( 'SELECT ta.task_id, ta.sprint_worker_id, ta.days FROM task_assignments ta JOIN tasks t ON t.id = ta.task_id WHERE t.sprint_id = ?' ); $stmt->execute([$sprintId]); $out = []; foreach ($stmt as $row) { $tid = (int) $row['task_id']; $sw = (int) $row['sprint_worker_id']; $d = (float) $row['days']; $out[$tid][$sw] = $d; } return $out; } /** * Status grid for a sprint: [task_id][sw_id] => status. Mirrors * gridForSprint(); cells with no row are absent (default to * STATUS_ZUGEWIESEN at the call site). * * @return array> */ public function statusGridForSprint(int $sprintId): array { $stmt = $this->pdo->prepare( 'SELECT ta.task_id, ta.sprint_worker_id, ta.status FROM task_assignments ta JOIN tasks t ON t.id = ta.task_id WHERE t.sprint_id = ?' ); $stmt->execute([$sprintId]); $out = []; foreach ($stmt as $row) { $tid = (int) $row['task_id']; $sw = (int) $row['sprint_worker_id']; $out[$tid][$sw] = (string) $row['status']; } return $out; } /** * Σ task_assignments.days where task.priority = 1, grouped by sprint_worker_id. * Used by CapacityCalculator via SprintController::show. * * @return array */ public function committedPrio1BySprint(int $sprintId): array { $stmt = $this->pdo->prepare( 'SELECT ta.sprint_worker_id, SUM(ta.days) AS committed FROM task_assignments ta JOIN tasks t ON t.id = ta.task_id WHERE t.sprint_id = ? AND t.priority = 1 GROUP BY ta.sprint_worker_id' ); $stmt->execute([$sprintId]); $out = []; foreach ($stmt as $row) { $out[(int) $row['sprint_worker_id']] = (float) $row['committed']; } return $out; } /** * Set days for one (task, sprint_worker) cell with the same four-case * semantics as SprintWorkerDayRepository::upsert: * - empty cell, days=0 -> NOOP * - empty cell, days>0 -> CREATE (status seeded with default) * - existing, unchanged -> NOOP * - existing, changed -> UPDATE (row kept when zeroed; status preserved) * * @return array{action:string, before: ?TaskAssignment, after: ?TaskAssignment} */ public function upsert(int $taskId, int $swId, float $days): array { $existing = $this->find($taskId, $swId); if ($existing !== null && abs($existing->days - $days) < 1e-9) { return ['action' => 'NOOP', 'before' => $existing, 'after' => $existing]; } if ($existing === null) { if (abs($days) < 1e-9) { return ['action' => 'NOOP', 'before' => null, 'after' => null]; } $stmt = $this->pdo->prepare( 'INSERT INTO task_assignments (task_id, sprint_worker_id, days) VALUES (?, ?, ?)' ); $stmt->execute([$taskId, $swId, $days]); $id = (int) $this->pdo->lastInsertId(); $after = new TaskAssignment($id, $taskId, $swId, $days, TaskAssignment::STATUS_ZUGEWIESEN); return ['action' => 'CREATE', 'before' => null, 'after' => $after]; } $stmt = $this->pdo->prepare('UPDATE task_assignments SET days = ? WHERE id = ?'); $stmt->execute([$days, $existing->id]); $after = new TaskAssignment($existing->id, $taskId, $swId, $days, $existing->status); return ['action' => 'UPDATE', 'before' => $existing, 'after' => $after]; } /** * Set status for one (task, sprint_worker) cell. Creates a row with * days=0 when none exists so a state can be tracked even before any * days are assigned. * * @return array{action:string, before: ?TaskAssignment, after: ?TaskAssignment} */ public function upsertStatus(int $taskId, int $swId, string $status): array { if (!TaskAssignment::isValidStatus($status)) { throw new InvalidArgumentException("invalid status: {$status}"); } $existing = $this->find($taskId, $swId); if ($existing !== null && $existing->status === $status) { return ['action' => 'NOOP', 'before' => $existing, 'after' => $existing]; } if ($existing === null) { // Default status on a fresh row would be 'zugewiesen', so writing // that is a no-op — saves an audit row for cells that never had // a non-default state. if ($status === TaskAssignment::STATUS_ZUGEWIESEN) { return ['action' => 'NOOP', 'before' => null, 'after' => null]; } $stmt = $this->pdo->prepare( 'INSERT INTO task_assignments (task_id, sprint_worker_id, days, status) VALUES (?, ?, 0, ?)' ); $stmt->execute([$taskId, $swId, $status]); $id = (int) $this->pdo->lastInsertId(); $after = new TaskAssignment($id, $taskId, $swId, 0.0, $status); return ['action' => 'CREATE', 'before' => null, 'after' => $after]; } $stmt = $this->pdo->prepare('UPDATE task_assignments SET status = ? WHERE id = ?'); $stmt->execute([$status, $existing->id]); $after = new TaskAssignment( $existing->id, $taskId, $swId, $existing->days, $status, ); return ['action' => 'UPDATE', 'before' => $existing, 'after' => $after]; } /** * @param array $row */ private static function hydrate(array $row): TaskAssignment { return new TaskAssignment( id: (int) $row['id'], taskId: (int) $row['task_id'], sprintWorkerId: (int) $row['sprint_worker_id'], days: (float) $row['days'], status: (string) ($row['status'] ?? TaskAssignment::STATUS_ZUGEWIESEN), ); } }