|
|
@@ -359,6 +359,32 @@ final class SprintController
|
|
|
return Response::err('validation', 'end_date must be on or after start_date', 422);
|
|
|
}
|
|
|
|
|
|
+ // When the date range moves, the week set is fully derived from it:
|
|
|
+ // count = floor((end - start)/7) + 1, capped at 26.
|
|
|
+ $datesChanged = isset($changes['start_date']) || isset($changes['end_date']);
|
|
|
+ $targetWeeks = null;
|
|
|
+ $cascadedDays = [];
|
|
|
+ if ($datesChanged) {
|
|
|
+ $targetWeeks = self::weeksBetween($effectiveStart, $effectiveEnd);
|
|
|
+ if ($targetWeeks > 26) {
|
|
|
+ return Response::err(
|
|
|
+ 'validation',
|
|
|
+ 'Date range spans more than 26 weeks',
|
|
|
+ 422,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ // Snapshot cells that would be cascade-deleted by a shrink BEFORE
|
|
|
+ // we open the transaction, so the audit has the rows it needs.
|
|
|
+ $existing = $this->weeks->allForSprint($id);
|
|
|
+ if ($targetWeeks < count($existing)) {
|
|
|
+ foreach (array_slice($existing, $targetWeeks) as $w) {
|
|
|
+ foreach ($this->days->allForSprintWeek($w->id) as $d) {
|
|
|
+ $cascadedDays[] = $d;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
if ($changes === []) {
|
|
|
return Response::ok(['sprint' => $sprint->toAuditSnapshot()]);
|
|
|
}
|
|
|
@@ -372,13 +398,69 @@ final class SprintController
|
|
|
$result['after']->toAuditSnapshot(),
|
|
|
$req, $actor,
|
|
|
);
|
|
|
+
|
|
|
+ if ($datesChanged && $targetWeeks !== null) {
|
|
|
+ // 1) Realign existing rows' start_date/iso_week to the new offset.
|
|
|
+ foreach ($this->weeks->realignDates($id, $effectiveStart) as $d) {
|
|
|
+ $this->audit->recordForRequest(
|
|
|
+ 'UPDATE', 'sprint_week', $d['after']->id,
|
|
|
+ $d['before']->toAuditSnapshot(),
|
|
|
+ $d['after']->toAuditSnapshot(),
|
|
|
+ $req, $actor,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ // 2) Audit cells that the upcoming shrink will cascade.
|
|
|
+ foreach ($cascadedDays as $cell) {
|
|
|
+ $this->audit->recordForRequest(
|
|
|
+ 'DELETE', 'sprint_worker_days', $cell->id,
|
|
|
+ $cell->toAuditSnapshot(), null,
|
|
|
+ $req, $actor,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ // 3) Resize the week set.
|
|
|
+ $diff = $this->weeks->syncCount($id, $effectiveStart, $targetWeeks);
|
|
|
+ foreach ($diff['added'] as $w) {
|
|
|
+ $this->audit->recordForRequest(
|
|
|
+ 'CREATE', 'sprint_week', $w->id,
|
|
|
+ null, $w->toAuditSnapshot(),
|
|
|
+ $req, $actor,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ foreach ($diff['removed'] as $w) {
|
|
|
+ $this->audit->recordForRequest(
|
|
|
+ 'DELETE', 'sprint_week', $w->id,
|
|
|
+ $w->toAuditSnapshot(), null,
|
|
|
+ $req, $actor,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
$this->pdo->commit();
|
|
|
} catch (Throwable) {
|
|
|
$this->pdo->rollBack();
|
|
|
return Response::err('db_error', 'Could not save sprint', 500);
|
|
|
}
|
|
|
|
|
|
- return Response::ok(['sprint' => $result['after']->toAuditSnapshot()]);
|
|
|
+ return Response::ok([
|
|
|
+ 'sprint' => $result['after']->toAuditSnapshot(),
|
|
|
+ 'weeks_synced' => $datesChanged,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Number of calendar weeks the inclusive range [start..end] spans.
|
|
|
+ * Matches the "every 7-day block since start" model used by
|
|
|
+ * {@see SprintWeekRepository::syncCount()}.
|
|
|
+ */
|
|
|
+ private static function weeksBetween(string $startYmd, string $endYmd): int
|
|
|
+ {
|
|
|
+ $start = DateTimeImmutable::createFromFormat('Y-m-d', $startYmd);
|
|
|
+ $end = DateTimeImmutable::createFromFormat('Y-m-d', $endYmd);
|
|
|
+ if ($start === false || $end === false) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ $days = (int) $start->diff($end)->format('%r%a');
|
|
|
+ return max(1, intdiv($days, 7) + 1);
|
|
|
}
|
|
|
|
|
|
/** POST /sprints/{id}/weeks — JSON — resize the week set. */
|