Jelajahi Sumber

Phase 20: XLSX import wizard (PhpSpreadsheet + colour→status)

Two-step admin-only wizard at /sprints/import that ingests the team's
historical Tool_Sprint Planning workbook one tab per sprint.

Step 1 (POST /sprints/import) is a multipart upload with strict
validation: ≤5 MB, .xlsx extension, PK\x03\x04/\x05\x06 ZIP magic-byte
check, CSRF. The parsed result is stashed under a 32-char hex token in
$_SESSION['sp_imports'][$token] (TTL 30 min); the file itself is never
written to disk.

Step 2 (GET /sprints/import/{token}) shows one panel per sheet with a
sprint-name input pre-filled from the tab name, inferred start/end
dates derived from the KW row + closest-year heuristic, reserve
fraction read-only from the workbook, target picker (Create new sprint
or Merge into empty existing sprint — non-empty sprints are filtered
out server-side), diff summary listing workers-to-create + cell
totals + per-status colour counts, and a per-sheet skip toggle.

Step 3 (POST /sprints/import/{token}) commits each selected sheet in
its own transaction with full audit-log coverage: sprint CREATE +
IMPORTED_FROM_XLSX anchor, sprint_week CREATEs (active_days_mask =
(1<<maxDays)-1, max_working_days = popcount), worker auto-create by
case-folded name, sprint_worker + sprint_worker_day rows, task
CREATEs (owners that don't resolve to any worker drop into
ImportResult::missingOwners and the task is created with no owner),
and task_assignment days + status via the existing repository upserts
so the audit semantics carry over.

New code:
  src/Domain/Import/{ParsedSheet,ParsedWeek,ParsedWorker,ParsedTask,
                     ParsedAssignment,ImportResult}.php
  src/Services/Import/XlsxColorClassifier.php   pure ARGB→status (HSL)
  src/Services/Import/XlsxSprintImporter.php    parser, fixed coords
  src/Services/Import/SprintImporter.php        transactional commit
  src/Controllers/ImportController.php          3 routes
  views/sprints/import_upload.twig
  views/sprints/import_preview.twig
  tests/Services/Import/{XlsxColorClassifier,XlsxSprintImporter,
                         SprintImporterCommit}Test.php
  tests/Controllers/ImportControllerTest.php

Touched:
  composer.json/.lock      + phpoffice/phpspreadsheet ^3.4
  Dockerfile               + ext-zip + ext-gd (PhpSpreadsheet require)
  public/index.php         DI + 4 routes
  views/layout.twig        "Import" admin nav link

Colour classifier (pure, no PhpSpreadsheet dep) buckets by hue/sat:
  greens H 80..170, S>0.15  → abgeschlossen
  yellows+oranges H 20..80  → gestartet
  reds H 340..20, S>0.20    → abgebrochen
  near-white L>0.96/S<0.20, desaturated S<0.10 banding, blue/teal/
  purple, no-fill, transparent → zugewiesen (default)

Parser tolerates up to 2 consecutive empty rows in both the
Arbeitstage and Tasks blocks so the sample workbook's row-13 visual
gap in Sprint 2 doesn't truncate the scan. Task-column → worker
mapping reads row-4 formulas (=C9, =C10, …) via getOldCalculatedValue()
with row-9 literal as fallback, so it follows Arbeitstage gaps.

Verified against doc/Tool_Sprint Planning.xlsx:
  Sprint 1 = 5 weeks / 15 workers / >20 tasks / 0.2 reserve
  Sprint 2 = 5 weeks / 16 workers (gap tolerated) /
             27 yellow → gestartet + 5 green → abgeschlossen
  toArray/fromArray round-trips every parsed sheet.

Tests: 143 / 392 (was 108 / 281) — +20 colour-classifier table cases,
+5 parser smoke cases against the fixture (auto-skipped on hosts that
lack ext-dom/zip/xmlreader/simplexml/gd), +5 commit-service cases on
the in-memory SQLite harness, +3 controller-static guard cases via
reflection (ZIP magic byte, wrong extension, UPLOAD_ERR_* mapping).

Strict CSP unchanged — no JS, native form posts throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 2 hari lalu
induk
melakukan
8876239727

+ 3 - 0
.gitignore

@@ -8,6 +8,9 @@
 *.sqlite-journal
 /node_modules/
 /.phpunit.cache/
+# Locally-bootstrapped composer.phar used to run composer without a global install.
+/composer
+/composer.phar
 # Claude Code agent runtime — worktrees + session scratch.
 /.claude/
 # Compiled by the Docker CSS builder stage (or `npm run build:css` for local dev).

+ 3 - 2
Dockerfile

@@ -32,8 +32,9 @@ RUN mkdir -p /build/vendor \
 FROM php:8.3-apache
 
 RUN apt-get update && apt-get install -y --no-install-recommends \
-        libsqlite3-dev unzip git \
-    && docker-php-ext-install pdo pdo_sqlite \
+        libsqlite3-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev unzip git \
+    && docker-php-ext-configure gd --with-freetype --with-jpeg \
+    && docker-php-ext-install pdo pdo_sqlite zip gd \
     && a2enmod rewrite \
     && rm -rf /var/lib/apt/lists/*
 

+ 1 - 0
composer.json

@@ -9,6 +9,7 @@
         "ext-pdo": "*",
         "ext-pdo_sqlite": "*",
         "jumbojett/openid-connect-php": "^1.0",
+        "phpoffice/phpspreadsheet": "^3.4",
         "twig/twig": "^3.10",
         "vlucas/phpdotenv": "^5.6"
     },

+ 422 - 1
composer.lock

@@ -4,8 +4,87 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "dc4951bc5c14604d73cccda58d69f36c",
+    "content-hash": "3c7192bad665e0f120e1daa4c613cd91",
     "packages": [
+        {
+            "name": "composer/pcre",
+            "version": "3.3.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/pcre.git",
+                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.4 || ^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan": "<1.11.10"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.12 || ^2",
+                "phpstan/phpstan-strict-rules": "^1 || ^2",
+                "phpunit/phpunit": "^8 || ^9"
+            },
+            "type": "library",
+            "extra": {
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
+                },
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Pcre\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                }
+            ],
+            "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+            "keywords": [
+                "PCRE",
+                "preg",
+                "regex",
+                "regular expression"
+            ],
+            "support": {
+                "issues": "https://github.com/composer/pcre/issues",
+                "source": "https://github.com/composer/pcre/tree/3.3.2"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-12T16:29:46+00:00"
+        },
         {
             "name": "graham-campbell/result-type",
             "version": "v1.1.4",
@@ -110,6 +189,191 @@
             },
             "time": "2024-09-13T07:08:11+00:00"
         },
+        {
+            "name": "maennchen/zipstream-php",
+            "version": "3.2.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/maennchen/ZipStream-PHP.git",
+                "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+                "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "ext-zlib": "*",
+                "php-64bit": "^8.3"
+            },
+            "require-dev": {
+                "brianium/paratest": "^7.7",
+                "ext-zip": "*",
+                "friendsofphp/php-cs-fixer": "^3.86",
+                "guzzlehttp/guzzle": "^7.5",
+                "mikey179/vfsstream": "^1.6",
+                "php-coveralls/php-coveralls": "^2.5",
+                "phpunit/phpunit": "^12.0",
+                "vimeo/psalm": "^6.0"
+            },
+            "suggest": {
+                "guzzlehttp/psr7": "^2.4",
+                "psr/http-message": "^2.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "ZipStream\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Paul Duncan",
+                    "email": "pabs@pablotron.org"
+                },
+                {
+                    "name": "Jonatan Männchen",
+                    "email": "jonatan@maennchen.ch"
+                },
+                {
+                    "name": "Jesse Donat",
+                    "email": "donatj@gmail.com"
+                },
+                {
+                    "name": "András Kolesár",
+                    "email": "kolesar@kolesar.hu"
+                }
+            ],
+            "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
+            "keywords": [
+                "stream",
+                "zip"
+            ],
+            "support": {
+                "issues": "https://github.com/maennchen/ZipStream-PHP/issues",
+                "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/maennchen",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-04-11T18:38:28+00:00"
+        },
+        {
+            "name": "markbaker/complex",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/MarkBaker/PHPComplex.git",
+                "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+                "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "require-dev": {
+                "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+                "phpcompatibility/php-compatibility": "^9.3",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+                "squizlabs/php_codesniffer": "^3.7"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Complex\\": "classes/src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mark Baker",
+                    "email": "mark@lange.demon.co.uk"
+                }
+            ],
+            "description": "PHP Class for working with complex numbers",
+            "homepage": "https://github.com/MarkBaker/PHPComplex",
+            "keywords": [
+                "complex",
+                "mathematics"
+            ],
+            "support": {
+                "issues": "https://github.com/MarkBaker/PHPComplex/issues",
+                "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
+            },
+            "time": "2022-12-06T16:21:08+00:00"
+        },
+        {
+            "name": "markbaker/matrix",
+            "version": "3.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/MarkBaker/PHPMatrix.git",
+                "reference": "728434227fe21be27ff6d86621a1b13107a2562c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
+                "reference": "728434227fe21be27ff6d86621a1b13107a2562c",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+                "phpcompatibility/php-compatibility": "^9.3",
+                "phpdocumentor/phpdocumentor": "2.*",
+                "phploc/phploc": "^4.0",
+                "phpmd/phpmd": "2.*",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+                "sebastian/phpcpd": "^4.0",
+                "squizlabs/php_codesniffer": "^3.7"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Matrix\\": "classes/src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mark Baker",
+                    "email": "mark@demon-angel.eu"
+                }
+            ],
+            "description": "PHP Class for working with matrices",
+            "homepage": "https://github.com/MarkBaker/PHPMatrix",
+            "keywords": [
+                "mathematics",
+                "matrix",
+                "vector"
+            ],
+            "support": {
+                "issues": "https://github.com/MarkBaker/PHPMatrix/issues",
+                "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
+            },
+            "time": "2022-12-02T22:17:43+00:00"
+        },
         {
             "name": "paragonie/constant_time_encoding",
             "version": "v3.1.3",
@@ -229,6 +493,112 @@
             },
             "time": "2020-10-15T08:29:30+00:00"
         },
+        {
+            "name": "phpoffice/phpspreadsheet",
+            "version": "3.10.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
+                "reference": "13f709c73e417c3c8cadc87ac88f809fd8d1fa45"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/13f709c73e417c3c8cadc87ac88f809fd8d1fa45",
+                "reference": "13f709c73e417c3c8cadc87ac88f809fd8d1fa45",
+                "shasum": ""
+            },
+            "require": {
+                "composer/pcre": "^1 || ^2 || ^3",
+                "ext-ctype": "*",
+                "ext-dom": "*",
+                "ext-fileinfo": "*",
+                "ext-gd": "*",
+                "ext-iconv": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-simplexml": "*",
+                "ext-xml": "*",
+                "ext-xmlreader": "*",
+                "ext-xmlwriter": "*",
+                "ext-zip": "*",
+                "ext-zlib": "*",
+                "maennchen/zipstream-php": "^2.1 || ^3.0",
+                "markbaker/complex": "^3.0",
+                "markbaker/matrix": "^3.0",
+                "php": "^8.1",
+                "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
+            },
+            "require-dev": {
+                "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
+                "dompdf/dompdf": "^2.0 || ^3.0",
+                "friendsofphp/php-cs-fixer": "^3.2",
+                "mitoteam/jpgraph": "^10.5",
+                "mpdf/mpdf": "^8.1.1",
+                "phpcompatibility/php-compatibility": "^9.3",
+                "phpstan/phpstan": "^1.1",
+                "phpstan/phpstan-phpunit": "^1.0",
+                "phpunit/phpunit": "^10.5",
+                "squizlabs/php_codesniffer": "^3.7",
+                "tecnickcom/tcpdf": "^6.5"
+            },
+            "suggest": {
+                "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
+                "ext-intl": "PHP Internationalization Functions, required for NumberFormatter Wizard",
+                "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
+                "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
+                "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Maarten Balliauw",
+                    "homepage": "https://blog.maartenballiauw.be"
+                },
+                {
+                    "name": "Mark Baker",
+                    "homepage": "https://markbakeruk.net"
+                },
+                {
+                    "name": "Franck Lefevre",
+                    "homepage": "https://rootslabs.net"
+                },
+                {
+                    "name": "Erik Tilt"
+                },
+                {
+                    "name": "Adrien Crivelli"
+                },
+                {
+                    "name": "Owen Leibman"
+                }
+            ],
+            "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
+            "homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
+            "keywords": [
+                "OpenXML",
+                "excel",
+                "gnumeric",
+                "ods",
+                "php",
+                "spreadsheet",
+                "xls",
+                "xlsx"
+            ],
+            "support": {
+                "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
+                "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/3.10.5"
+            },
+            "time": "2026-04-19T05:54:30+00:00"
+        },
         {
             "name": "phpoption/phpoption",
             "version": "1.9.5",
@@ -414,6 +784,57 @@
             ],
             "time": "2026-04-27T07:02:15+00:00"
         },
+        {
+            "name": "psr/simple-cache",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/simple-cache.git",
+                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\SimpleCache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interfaces for simple caching",
+            "keywords": [
+                "cache",
+                "caching",
+                "psr",
+                "psr-16",
+                "simple-cache"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+            },
+            "time": "2021-10-29T13:26:27+00:00"
+        },
         {
             "name": "symfony/deprecation-contracts",
             "version": "v3.7.0",

+ 16 - 0
public/index.php

@@ -7,6 +7,7 @@ use App\Auth\OidcClient;
 use App\Auth\SessionGuard;
 use App\Controllers\AuditController;
 use App\Controllers\AuthController;
+use App\Controllers\ImportController;
 use App\Controllers\SettingsController;
 use App\Controllers\SprintController;
 use App\Controllers\TaskController;
@@ -29,6 +30,8 @@ use App\Repositories\TaskRepository;
 use App\Repositories\UserRepository;
 use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
+use App\Services\Import\SprintImporter;
+use App\Services\Import\XlsxSprintImporter;
 
 // Buffer output so a stray warning/notice can't send headers before
 // Response::send() gets a chance to set them. send() will flush.
@@ -113,6 +116,14 @@ $taskCtrl       = new TaskController(
 $auditCtrl      = new AuditController($users, $auditRepo, $view);
 $userCtrl       = new UserController($pdo, $users, $audit, $view);
 $settingsCtrl   = new SettingsController($pdo, $users, $appSettings, $audit, $view);
+$xlsxParser     = new XlsxSprintImporter();
+$importCommit   = new SprintImporter(
+    $pdo, $sprints, $sprintWeeks, $sprintWorkers, $swDays,
+    $tasks, $taskAssign, $workers, $audit,
+);
+$importCtrl     = new ImportController(
+    $pdo, $users, $sprints, $xlsxParser, $importCommit, $view,
+);
 
 // ---------------------------------------------------------------------------
 // Routing
@@ -156,6 +167,11 @@ $router->post('/workers/{id}',   $workerCtrl->update(...));
 $router->get('/users',           $userCtrl->index(...));
 $router->post('/users/{id}',     $userCtrl->update(...));
 
+$router->get('/sprints/import',           $importCtrl->newForm(...));
+$router->post('/sprints/import',          $importCtrl->upload(...));
+$router->get('/sprints/import/{token}',   $importCtrl->preview(...));
+$router->post('/sprints/import/{token}',  $importCtrl->commit(...));
+
 $router->get('/sprints/new',              $sprintCtrl->newForm(...));
 $router->post('/sprints',                 $sprintCtrl->create(...));
 $router->get('/sprints/{id}',             $sprintCtrl->show(...));

+ 396 - 0
src/Controllers/ImportController.php

@@ -0,0 +1,396 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\Auth\SessionGuard;
+use App\Domain\Import\ImportResult;
+use App\Domain\Import\ParsedSheet;
+use App\Http\Request;
+use App\Http\Response;
+use App\Http\View;
+use App\Repositories\SprintRepository;
+use App\Repositories\UserRepository;
+use App\Services\Import\SprintImporter;
+use App\Services\Import\XlsxSprintImporter;
+use PDO;
+use Throwable;
+
+/**
+ * Phase 20 — Two-step XLSX → Sprint import wizard.
+ *
+ *   GET  /sprints/import           upload form
+ *   POST /sprints/import           parse, stash in session, redirect to preview
+ *   GET  /sprints/import/{token}   preview + per-sheet target picker
+ *   POST /sprints/import/{token}   commit selected sheets in one TX each,
+ *                                  redirect to the first sprint with a flash.
+ *
+ * The uploaded XLSX is parsed once and the structured result is stored in
+ * the session under a random token; the file itself is not persisted to disk.
+ * Tokens expire after 30 minutes.
+ */
+final class ImportController
+{
+    public const SESSION_KEY = 'sp_imports';
+    private const TTL_SECONDS = 1800;
+    private const MAX_FILE_BYTES = 5 * 1024 * 1024;
+
+    public function __construct(
+        private readonly PDO              $pdo,
+        private readonly UserRepository   $users,
+        private readonly SprintRepository $sprints,
+        private readonly XlsxSprintImporter $parser,
+        private readonly SprintImporter   $committer,
+        private readonly View             $view,
+    ) {
+    }
+
+    public function newForm(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        return Response::html($this->view->render('sprints/import_upload', [
+            'title'       => 'Import sprints',
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+            'error'       => $req->queryString('error'),
+        ]));
+    }
+
+    public function upload(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        // Validate the upload up-front; fail-closed.
+        if (!isset($_FILES['xlsx']) || !is_array($_FILES['xlsx'])) {
+            return Response::redirect('/sprints/import?error=no_file');
+        }
+        $file = $_FILES['xlsx'];
+        if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+            $code = self::uploadErrorCode((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE));
+            return Response::redirect('/sprints/import?error=' . $code);
+        }
+        $size = (int) ($file['size'] ?? 0);
+        if ($size <= 0 || $size > self::MAX_FILE_BYTES) {
+            return Response::redirect('/sprints/import?error=size');
+        }
+        $tmp = (string) ($file['tmp_name'] ?? '');
+        if ($tmp === '' || !is_uploaded_file($tmp)) {
+            return Response::redirect('/sprints/import?error=upload_invalid');
+        }
+        $orig = (string) ($file['name'] ?? '');
+        if (!self::looksLikeXlsx($orig, $tmp)) {
+            return Response::redirect('/sprints/import?error=not_xlsx');
+        }
+
+        try {
+            $sheets = $this->parser->parse($tmp);
+        } catch (Throwable) {
+            return Response::redirect('/sprints/import?error=parse_failed');
+        }
+
+        $token = bin2hex(random_bytes(16));
+        SessionGuard::start();
+        if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
+            $_SESSION[self::SESSION_KEY] = [];
+        }
+        $_SESSION[self::SESSION_KEY][$token] = [
+            'created_at' => time(),
+            'sheets'     => array_map(fn(ParsedSheet $s) => $s->toArray(), $sheets),
+            'file_name'  => basename($orig),
+        ];
+        $this->pruneSessionImports();
+
+        return Response::redirect('/sprints/import/' . $token);
+    }
+
+    public function preview(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        $token = (string) ($params['token'] ?? '');
+        $entry = $this->loadSessionEntry($token);
+        if ($entry === null) {
+            return Response::redirect('/sprints/import?error=expired');
+        }
+
+        $sheets = array_map(
+            static fn(array $arr) => ParsedSheet::fromArray($arr),
+            $entry['sheets'],
+        );
+
+        // Existing-sprint candidates for the per-sheet target picker: only
+        // empty sprints (no weeks/workers/tasks) qualify. Spec §plan §2.
+        $emptySprints = $this->emptySprintCandidates();
+
+        // Pre-compute summary stats for the diff panel.
+        $summaries = [];
+        $existingWorkerFolds = $this->existingWorkerFolds();
+        foreach ($sheets as $sheet) {
+            $summaries[] = $this->summarise($sheet, $existingWorkerFolds);
+        }
+
+        return Response::html($this->view->render('sprints/import_preview', [
+            'title'        => 'Import preview',
+            'currentUser'  => $actor,
+            'csrfToken'    => SessionGuard::csrfToken(),
+            'token'        => $token,
+            'fileName'     => (string) ($entry['file_name'] ?? ''),
+            'sheets'       => $sheets,
+            'summaries'    => $summaries,
+            'emptySprints' => $emptySprints,
+            'error'        => $req->queryString('error'),
+        ]));
+    }
+
+    public function commit(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+        $token = (string) ($params['token'] ?? '');
+        $entry = $this->loadSessionEntry($token);
+        if ($entry === null) {
+            return Response::redirect('/sprints/import?error=expired');
+        }
+        $sheets = array_map(
+            static fn(array $arr) => ParsedSheet::fromArray($arr),
+            $entry['sheets'],
+        );
+
+        $results = [];
+        foreach ($sheets as $idx => $sheet) {
+            $skip = $req->postString("skip_{$idx}");
+            if ($skip === '1') {
+                continue;
+            }
+            $name      = $req->postString("name_{$idx}");
+            $startDate = $req->postString("start_{$idx}");
+            $endDate   = $req->postString("end_{$idx}");
+            $target    = $req->postString("target_{$idx}");
+            $existing  = $req->postString("existing_{$idx}");
+            $existingId = ($target === 'existing' && $existing !== '' && ctype_digit($existing))
+                ? (int) $existing
+                : null;
+            if ($name === '') {
+                $name = $sheet->sheetName;
+            }
+            if ($startDate === '') {
+                $startDate = $sheet->inferredStartDate ?? '';
+            }
+            if ($endDate === '') {
+                $endDate = $sheet->inferredEndDate ?? '';
+            }
+
+            try {
+                $results[] = $this->committer->commit(
+                    sheet:             $sheet,
+                    sprintName:        $name,
+                    startDate:         $startDate,
+                    endDate:           $endDate,
+                    target:            $target === 'existing' ? 'existing' : 'new',
+                    existingSprintId:  $existingId,
+                    req:               $req,
+                    actor:             $actor,
+                );
+            } catch (Throwable $e) {
+                // Bail on first failure: prior sheets in this loop already
+                // committed, but each is a self-contained transaction so the
+                // user keeps the partial progress and can re-try the rest.
+                SessionGuard::start();
+                $_SESSION['flash_import_error'] = sprintf(
+                    'Sheet "%s" failed: %s',
+                    $sheet->sheetName,
+                    $e->getMessage(),
+                );
+                return Response::redirect('/sprints/import/' . $token . '?error=commit');
+            }
+        }
+
+        // Drop the session entry; the wizard is done.
+        if (isset($_SESSION[self::SESSION_KEY][$token])) {
+            unset($_SESSION[self::SESSION_KEY][$token]);
+        }
+
+        if ($results === []) {
+            return Response::redirect('/sprints/import?error=nothing_selected');
+        }
+
+        // Land on the first imported sprint with an audible flash.
+        SessionGuard::start();
+        $_SESSION['flash_import_summary'] = self::summariseResults($results);
+        return Response::redirect('/sprints/' . $results[0]->sprintId);
+    }
+
+    // ------------------------------------------------------------------ utils
+
+    /** @return array{sheets: list<array<string,mixed>>, file_name: string, created_at: int}|null */
+    private function loadSessionEntry(string $token): ?array
+    {
+        if (!preg_match('/^[0-9a-f]{32}$/', $token)) {
+            return null;
+        }
+        SessionGuard::start();
+        $bag = $_SESSION[self::SESSION_KEY] ?? [];
+        if (!is_array($bag) || !isset($bag[$token]) || !is_array($bag[$token])) {
+            return null;
+        }
+        $entry = $bag[$token];
+        $createdAt = (int) ($entry['created_at'] ?? 0);
+        if ($createdAt + self::TTL_SECONDS < time()) {
+            unset($_SESSION[self::SESSION_KEY][$token]);
+            return null;
+        }
+        return $entry;
+    }
+
+    private function pruneSessionImports(): void
+    {
+        SessionGuard::start();
+        $bag = $_SESSION[self::SESSION_KEY] ?? [];
+        if (!is_array($bag)) {
+            return;
+        }
+        $cutoff = time() - self::TTL_SECONDS;
+        foreach ($bag as $tok => $row) {
+            if (!is_array($row) || (int) ($row['created_at'] ?? 0) < $cutoff) {
+                unset($_SESSION[self::SESSION_KEY][$tok]);
+            }
+        }
+    }
+
+    /** @return list<array{id:int,name:string,startDate:string,endDate:string}> */
+    private function emptySprintCandidates(): array
+    {
+        $stmt = $this->pdo->query(
+            'SELECT s.id, s.name, s.start_date, s.end_date
+             FROM sprints s
+             WHERE NOT EXISTS (SELECT 1 FROM sprint_weeks   WHERE sprint_id = s.id)
+               AND NOT EXISTS (SELECT 1 FROM sprint_workers WHERE sprint_id = s.id)
+               AND NOT EXISTS (SELECT 1 FROM tasks          WHERE sprint_id = s.id)
+             ORDER BY s.start_date DESC, s.id DESC'
+        );
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = [
+                'id'        => (int) $row['id'],
+                'name'      => (string) $row['name'],
+                'startDate' => (string) $row['start_date'],
+                'endDate'   => (string) $row['end_date'],
+            ];
+        }
+        return $out;
+    }
+
+    /** @return array<string,bool> case-folded name => true */
+    private function existingWorkerFolds(): array
+    {
+        $stmt = $this->pdo->query('SELECT name FROM workers');
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[SprintImporter::fold((string) $row['name'])] = true;
+        }
+        return $out;
+    }
+
+    /**
+     * @param array<string,bool> $existingFolds
+     * @return array<string,mixed>
+     */
+    private function summarise(ParsedSheet $sheet, array $existingFolds): array
+    {
+        $newWorkers = [];
+        foreach ($sheet->workers as $w) {
+            if (!isset($existingFolds[SprintImporter::fold($w->name)])) {
+                $newWorkers[] = $w->name;
+            }
+        }
+        $cells = 0;
+        $byStatus = [
+            'zugewiesen'    => 0,
+            'gestartet'     => 0,
+            'abgeschlossen' => 0,
+            'abgebrochen'   => 0,
+        ];
+        foreach ($sheet->tasks as $t) {
+            foreach ($t->assignments as $a) {
+                if ($a->days > 0) {
+                    $cells++;
+                }
+                $byStatus[$a->status] = ($byStatus[$a->status] ?? 0) + 1;
+            }
+        }
+        return [
+            'newWorkers'    => $newWorkers,
+            'workerCount'   => count($sheet->workers),
+            'taskCount'     => count($sheet->tasks),
+            'weekCount'     => count($sheet->weeks),
+            'cellCount'     => $cells,
+            'statusCounts'  => $byStatus,
+        ];
+    }
+
+    /** @param list<ImportResult> $results */
+    private static function summariseResults(array $results): string
+    {
+        $parts = [];
+        foreach ($results as $r) {
+            $bits = ["{$r->workerCount} workers", "{$r->taskCount} tasks", "{$r->assignmentCellCount} cells"];
+            if ($r->createdWorkers !== []) {
+                $bits[] = count($r->createdWorkers) . ' new workers';
+            }
+            $parts[] = "“{$r->sprintName}” imported (" . implode(', ', $bits) . ').';
+        }
+        return implode(' ', $parts);
+    }
+
+    private static function looksLikeXlsx(string $origName, string $tmpPath): bool
+    {
+        if (!preg_match('/\.xlsx$/i', $origName)) {
+            return false;
+        }
+        // XLSX is a ZIP container; the file's first 4 bytes start with PK\x03\x04 or PK\x05\x06.
+        $fh = @fopen($tmpPath, 'rb');
+        if ($fh === false) {
+            return false;
+        }
+        $head = (string) fread($fh, 4);
+        fclose($fh);
+        if (strlen($head) !== 4) {
+            return false;
+        }
+        return $head[0] === 'P' && $head[1] === 'K'
+            && (($head[2] === "\x03" && $head[3] === "\x04")
+             || ($head[2] === "\x05" && $head[3] === "\x06"));
+    }
+
+    private static function uploadErrorCode(int $code): string
+    {
+        return match ($code) {
+            UPLOAD_ERR_INI_SIZE,
+            UPLOAD_ERR_FORM_SIZE => 'too_big',
+            UPLOAD_ERR_PARTIAL   => 'partial',
+            UPLOAD_ERR_NO_FILE   => 'no_file',
+            UPLOAD_ERR_NO_TMP_DIR,
+            UPLOAD_ERR_CANT_WRITE,
+            UPLOAD_ERR_EXTENSION => 'server',
+            default              => 'unknown',
+        };
+    }
+}

+ 29 - 0
src/Domain/Import/ImportResult.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Import;
+
+/**
+ * Outcome of committing one ParsedSheet, returned from SprintImporter::commit
+ * so the controller can compose a flash summary.
+ */
+final class ImportResult
+{
+    /**
+     * @param list<string> $createdWorkers names of workers added to the global Workers table
+     * @param list<string> $missingOwners  task-owner names that did not match any worker (silently dropped to "no owner")
+     */
+    public function __construct(
+        public readonly int    $sprintId,
+        public readonly string $sprintName,
+        public readonly int    $weekCount,
+        public readonly int    $workerCount,
+        public readonly int    $taskCount,
+        public readonly int    $assignmentCellCount,
+        public readonly int    $statusCellCount,
+        public readonly array  $createdWorkers,
+        public readonly array  $missingOwners,
+    ) {
+    }
+}

+ 24 - 0
src/Domain/Import/ParsedAssignment.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Import;
+
+/**
+ * One (task, worker) cell from the Tasks block.
+ *
+ * `status` is one of TaskAssignment::STATUS_*, derived from the cell fill
+ * colour by XlsxColorClassifier. `argbHex` is kept around for the preview
+ * UI (so the user can sanity-check the colour mapping per cell if they
+ * want to drill in).
+ */
+final class ParsedAssignment
+{
+    public function __construct(
+        public readonly string  $workerName,
+        public readonly float   $days,
+        public readonly string  $status,
+        public readonly ?string $argbHex = null,
+    ) {
+    }
+}

+ 102 - 0
src/Domain/Import/ParsedSheet.php

@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Import;
+
+/**
+ * Parsed result for a single XLSX sheet (one prospective sprint).
+ *
+ * Pure data — no PDO, no PhpSpreadsheet. Round-trippable to/from JSON for
+ * stashing in the session between the upload and the preview/commit steps.
+ */
+final class ParsedSheet
+{
+    /**
+     * @param list<ParsedWeek>   $weeks
+     * @param list<ParsedWorker> $workers
+     * @param list<ParsedTask>   $tasks
+     * @param list<string>       $warnings non-fatal observations the wizard surfaces
+     */
+    public function __construct(
+        public readonly string  $sheetName,
+        public readonly array   $weeks,
+        public readonly array   $workers,
+        public readonly array   $tasks,
+        public readonly float   $reserveFraction,
+        public readonly ?string $inferredStartDate,
+        public readonly ?string $inferredEndDate,
+        public readonly array   $warnings,
+    ) {
+    }
+
+    /** @return array<string,mixed> */
+    public function toArray(): array
+    {
+        return [
+            'sheetName'         => $this->sheetName,
+            'reserveFraction'   => $this->reserveFraction,
+            'inferredStartDate' => $this->inferredStartDate,
+            'inferredEndDate'   => $this->inferredEndDate,
+            'warnings'          => $this->warnings,
+            'weeks' => array_map(fn(ParsedWeek $w) => [
+                'sortOrder'         => $w->sortOrder,
+                'kw'                => $w->kw,
+                'dateLabel'         => $w->dateLabel,
+                'maxWorkingDays'    => $w->maxWorkingDays,
+                'inferredStartDate' => $w->inferredStartDate,
+            ], $this->weeks),
+            'workers' => array_map(fn(ParsedWorker $p) => [
+                'name'        => $p->name,
+                'daysPerWeek' => $p->daysPerWeek,
+                'rtb'         => $p->rtb,
+            ], $this->workers),
+            'tasks' => array_map(fn(ParsedTask $t) => [
+                'title'     => $t->title,
+                'ownerName' => $t->ownerName,
+                'priority'  => $t->priority,
+                'assignments' => array_map(fn(ParsedAssignment $a) => [
+                    'workerName' => $a->workerName,
+                    'days'       => $a->days,
+                    'status'     => $a->status,
+                    'argbHex'    => $a->argbHex,
+                ], $t->assignments),
+            ], $this->tasks),
+        ];
+    }
+
+    /** @param array<string,mixed> $arr */
+    public static function fromArray(array $arr): self
+    {
+        return new self(
+            sheetName:         (string) $arr['sheetName'],
+            weeks: array_map(fn($w) => new ParsedWeek(
+                sortOrder:         (int)    $w['sortOrder'],
+                kw:                (int)    $w['kw'],
+                dateLabel:         (string) $w['dateLabel'],
+                maxWorkingDays:    (int)    $w['maxWorkingDays'],
+                inferredStartDate: $w['inferredStartDate'] !== null ? (string) $w['inferredStartDate'] : null,
+            ), (array) ($arr['weeks'] ?? [])),
+            workers: array_map(fn($w) => new ParsedWorker(
+                name:        (string) $w['name'],
+                daysPerWeek: array_map(fn($v) => (float) $v, (array) ($w['daysPerWeek'] ?? [])),
+                rtb:         (float)  $w['rtb'],
+            ), (array) ($arr['workers'] ?? [])),
+            tasks: array_map(fn($t) => new ParsedTask(
+                title:     (string) $t['title'],
+                ownerName: $t['ownerName'] !== null ? (string) $t['ownerName'] : null,
+                priority:  (int)    $t['priority'],
+                assignments: array_map(fn($a) => new ParsedAssignment(
+                    workerName: (string) $a['workerName'],
+                    days:       (float)  $a['days'],
+                    status:     (string) $a['status'],
+                    argbHex:    $a['argbHex'] !== null ? (string) $a['argbHex'] : null,
+                ), (array) ($t['assignments'] ?? [])),
+            ), (array) ($arr['tasks'] ?? [])),
+            reserveFraction:   (float) ($arr['reserveFraction']   ?? 0.2),
+            inferredStartDate: $arr['inferredStartDate'] !== null ? (string) $arr['inferredStartDate'] : null,
+            inferredEndDate:   $arr['inferredEndDate']   !== null ? (string) $arr['inferredEndDate']   : null,
+            warnings:          array_map(fn($w) => (string) $w, (array) ($arr['warnings'] ?? [])),
+        );
+    }
+}

+ 19 - 0
src/Domain/Import/ParsedTask.php

@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Import;
+
+final class ParsedTask
+{
+    /**
+     * @param list<ParsedAssignment> $assignments only cells with days > 0 OR a non-default status
+     */
+    public function __construct(
+        public readonly string  $title,
+        public readonly ?string $ownerName,
+        public readonly int     $priority,
+        public readonly array   $assignments,
+    ) {
+    }
+}

+ 26 - 0
src/Domain/Import/ParsedWeek.php

@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Import;
+
+/**
+ * One Arbeitstage-block week column extracted from a sheet.
+ *
+ * `dateLabel` is the raw cell value (e.g. "23.03" or "23.03.2026") — kept as
+ * a string so we can show the user what was on the sheet even when no real
+ * date can be inferred. The parser sets `inferredStartDate` (Y-m-d) when it
+ * can derive one from `kw` + a year hint; otherwise null and the wizard
+ * prompts the operator.
+ */
+final class ParsedWeek
+{
+    public function __construct(
+        public readonly int     $sortOrder,
+        public readonly int     $kw,
+        public readonly string  $dateLabel,
+        public readonly int     $maxWorkingDays,
+        public readonly ?string $inferredStartDate = null,
+    ) {
+    }
+}

+ 24 - 0
src/Domain/Import/ParsedWorker.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Import;
+
+/**
+ * One Arbeitstage-block worker row.
+ *
+ * `daysPerWeek` is keyed by week sort_order (1-based) for stable cross-
+ * referencing into ParsedSheet::$weeks.
+ */
+final class ParsedWorker
+{
+    /**
+     * @param array<int, float> $daysPerWeek week sortOrder => days
+     */
+    public function __construct(
+        public readonly string $name,
+        public readonly array  $daysPerWeek,
+        public readonly float  $rtb,
+    ) {
+    }
+}

+ 382 - 0
src/Services/Import/SprintImporter.php

@@ -0,0 +1,382 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Import;
+
+use App\Domain\Import\ImportResult;
+use App\Domain\Import\ParsedSheet;
+use App\Domain\SprintWeek;
+use App\Domain\TaskAssignment;
+use App\Domain\User;
+use App\Http\Request;
+use App\Repositories\SprintRepository;
+use App\Repositories\SprintWeekRepository;
+use App\Repositories\SprintWorkerDayRepository;
+use App\Repositories\SprintWorkerRepository;
+use App\Repositories\TaskAssignmentRepository;
+use App\Repositories\TaskRepository;
+use App\Repositories\WorkerRepository;
+use App\Services\AuditLogger;
+use InvalidArgumentException;
+use PDO;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Commit a ParsedSheet into the database.
+ *
+ * Modes:
+ *   - target='new'                   → create a fresh sprint with $sprintName
+ *   - target='existing', $sprintId   → import into an empty existing sprint
+ *                                      (no weeks, no workers, no tasks).
+ *
+ * Workers are matched by case-insensitive name; missing ones are auto-created
+ * (audit row each). Task owners that don't resolve to any worker are recorded
+ * in ImportResult::$missingOwners and the task is created with no owner.
+ *
+ * The whole commit runs in one transaction; on any failure the caller sees
+ * the underlying exception and nothing is persisted.
+ */
+final class SprintImporter
+{
+    public function __construct(
+        private readonly PDO                       $pdo,
+        private readonly SprintRepository          $sprints,
+        private readonly SprintWeekRepository      $weeks,
+        private readonly SprintWorkerRepository    $sprintWorkers,
+        private readonly SprintWorkerDayRepository $days,
+        private readonly TaskRepository            $tasks,
+        private readonly TaskAssignmentRepository  $assignments,
+        private readonly WorkerRepository          $workers,
+        private readonly AuditLogger               $audit,
+    ) {
+    }
+
+    /**
+     * @param 'new'|'existing' $target
+     */
+    public function commit(
+        ParsedSheet $sheet,
+        string $sprintName,
+        string $startDate,
+        string $endDate,
+        string $target,
+        ?int $existingSprintId,
+        Request $req,
+        ?User $actor,
+    ): ImportResult {
+        if ($target !== 'new' && $target !== 'existing') {
+            throw new InvalidArgumentException('target must be "new" or "existing"');
+        }
+        if ($target === 'existing' && $existingSprintId === null) {
+            throw new InvalidArgumentException('existingSprintId required when target="existing"');
+        }
+        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) ||
+            !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
+            throw new InvalidArgumentException('startDate/endDate must be Y-m-d');
+        }
+        if ($sprintName === '') {
+            throw new InvalidArgumentException('sprintName is required');
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            // --- 1. Resolve target sprint --------------------------------------
+            if ($target === 'new') {
+                $sprint = $this->sprints->create(
+                    name:            $sprintName,
+                    startDate:       $startDate,
+                    endDate:         $endDate,
+                    reserveFraction: $sheet->reserveFraction,
+                );
+                $this->audit->recordForRequest(
+                    action:     'CREATE',
+                    entityType: 'sprint',
+                    entityId:   $sprint->id,
+                    before:     null,
+                    after:      $sprint->toAuditSnapshot(),
+                    req:        $req,
+                    actor:      $actor,
+                );
+            } else {
+                $sprint = $this->sprints->find((int) $existingSprintId);
+                if ($sprint === null) {
+                    throw new RuntimeException("Sprint {$existingSprintId} not found");
+                }
+                if (!$this->isSprintEmpty($sprint->id)) {
+                    throw new RuntimeException(
+                        "Sprint {$sprint->id} is not empty; refuse to merge."
+                    );
+                }
+                // Apply the wizard-set name + dates + reserve fraction onto the
+                // existing empty sprint (typical case: user picked a sprint shell
+                // they prepared earlier).
+                $diff = $this->sprints->update($sprint->id, [
+                    'name'             => $sprintName,
+                    'start_date'       => $startDate,
+                    'end_date'         => $endDate,
+                    'reserve_fraction' => $sheet->reserveFraction,
+                ]);
+                $this->audit->recordForRequest(
+                    action:     'UPDATE',
+                    entityType: 'sprint',
+                    entityId:   $sprint->id,
+                    before:     $diff['before']->toAuditSnapshot(),
+                    after:      $diff['after']->toAuditSnapshot(),
+                    req:        $req,
+                    actor:      $actor,
+                );
+                $sprint = $diff['after'];
+            }
+            // Tag the sprint creation/merge as the importer's anchor row.
+            $this->audit->recordForRequest(
+                action:     'IMPORTED_FROM_XLSX',
+                entityType: 'sprint',
+                entityId:   $sprint->id,
+                before:     null,
+                after:      [
+                    'sheet_name'         => $sheet->sheetName,
+                    'week_count'         => count($sheet->weeks),
+                    'worker_count'       => count($sheet->workers),
+                    'task_count'         => count($sheet->tasks),
+                ],
+                req:        $req,
+                actor:      $actor,
+            );
+
+            // --- 2. Materialise weeks ------------------------------------------
+            $weekIds = []; // sortOrder => sprint_week_id
+            foreach ($sheet->weeks as $pw) {
+                $mask = self::maxDaysToMask($pw->maxWorkingDays);
+                $weekStart = $pw->inferredStartDate ?? $startDate;
+                $stmt = $this->pdo->prepare(
+                    'INSERT INTO sprint_weeks
+                     (sprint_id, sort_order, iso_week, start_date, max_working_days, active_days_mask)
+                     VALUES (?, ?, ?, ?, ?, ?)'
+                );
+                $stmt->execute([
+                    $sprint->id,
+                    $pw->sortOrder,
+                    $pw->kw,
+                    $weekStart,
+                    (float) SprintWeek::popcount($mask),
+                    $mask,
+                ]);
+                $wid = (int) $this->pdo->lastInsertId();
+                $weekIds[$pw->sortOrder] = $wid;
+                $this->audit->recordForRequest(
+                    action:     'CREATE',
+                    entityType: 'sprint_week',
+                    entityId:   $wid,
+                    before:     null,
+                    after:      [
+                        'id'               => $wid,
+                        'sprint_id'        => $sprint->id,
+                        'sort_order'       => $pw->sortOrder,
+                        'iso_week'         => $pw->kw,
+                        'start_date'       => $weekStart,
+                        'max_working_days' => (float) SprintWeek::popcount($mask),
+                        'active_days_mask' => $mask,
+                    ],
+                    req:        $req,
+                    actor:      $actor,
+                );
+            }
+
+            // --- 3. Resolve / create workers + sprint_workers + per-week days ---
+            $workerByFold = $this->buildWorkerLookup();
+            $createdWorkers = [];
+            $sprintWorkerIdByFold = [];
+            foreach ($sheet->workers as $pw) {
+                $fold = self::fold($pw->name);
+                if (!isset($workerByFold[$fold])) {
+                    $w = $this->workers->create($pw->name, true, 0.0);
+                    $workerByFold[$fold] = $w;
+                    $createdWorkers[] = $w->name;
+                    $this->audit->recordForRequest(
+                        action:     'CREATE',
+                        entityType: 'worker',
+                        entityId:   $w->id,
+                        before:     null,
+                        after:      $w->toAuditSnapshot(),
+                        req:        $req,
+                        actor:      $actor,
+                    );
+                }
+                $worker = $workerByFold[$fold];
+
+                $sw = $this->sprintWorkers->add($sprint->id, $worker->id, $pw->rtb);
+                $sprintWorkerIdByFold[$fold] = $sw->id;
+                $this->audit->recordForRequest(
+                    action:     'CREATE',
+                    entityType: 'sprint_worker',
+                    entityId:   $sw->id,
+                    before:     null,
+                    after:      $sw->toAuditSnapshot(),
+                    req:        $req,
+                    actor:      $actor,
+                );
+
+                foreach ($pw->daysPerWeek as $sortOrder => $days) {
+                    if ($days <= 0.0 || !isset($weekIds[$sortOrder])) {
+                        continue;
+                    }
+                    $r = $this->days->upsert($sw->id, $weekIds[$sortOrder], (float) $days);
+                    if ($r['action'] === 'CREATE' && $r['after'] !== null) {
+                        $this->audit->recordForRequest(
+                            action:     'CREATE',
+                            entityType: 'sprint_worker_day',
+                            entityId:   $r['after']->id,
+                            before:     null,
+                            after:      $r['after']->toAuditSnapshot(),
+                            req:        $req,
+                            actor:      $actor,
+                        );
+                    }
+                }
+            }
+
+            // --- 4. Tasks + assignments ----------------------------------------
+            $missingOwners  = [];
+            $assignmentCells = 0;
+            $statusCells     = 0;
+            foreach ($sheet->tasks as $pt) {
+                $ownerId = null;
+                if ($pt->ownerName !== null) {
+                    $ofold = self::fold($pt->ownerName);
+                    if (isset($workerByFold[$ofold])) {
+                        $ownerId = $workerByFold[$ofold]->id;
+                    } else {
+                        $missingOwners[] = $pt->ownerName;
+                    }
+                }
+
+                $task = $this->tasks->create(
+                    sprintId:      $sprint->id,
+                    title:         $pt->title,
+                    ownerWorkerId: $ownerId,
+                    priority:      $pt->priority,
+                );
+                $this->audit->recordForRequest(
+                    action:     'CREATE',
+                    entityType: 'task',
+                    entityId:   $task->id,
+                    before:     null,
+                    after:      $task->toAuditSnapshot(),
+                    req:        $req,
+                    actor:      $actor,
+                );
+
+                foreach ($pt->assignments as $pa) {
+                    $afold = self::fold($pa->workerName);
+                    if (!isset($sprintWorkerIdByFold[$afold])) {
+                        // Cell points at a worker not on this sprint — drop with a warning.
+                        // (Should not happen for correct workbooks, but be defensive.)
+                        continue;
+                    }
+                    $swId = $sprintWorkerIdByFold[$afold];
+
+                    if ($pa->days > 0.0) {
+                        $r = $this->assignments->upsert($task->id, $swId, $pa->days);
+                        if ($r['action'] === 'CREATE' && $r['after'] !== null) {
+                            $assignmentCells++;
+                            $this->audit->recordForRequest(
+                                action:     'CREATE',
+                                entityType: 'task_assignment',
+                                entityId:   $r['after']->id,
+                                before:     null,
+                                after:      $r['after']->toAuditSnapshot(),
+                                req:        $req,
+                                actor:      $actor,
+                            );
+                        }
+                    }
+
+                    if ($pa->status !== TaskAssignment::STATUS_ZUGEWIESEN) {
+                        $r = $this->assignments->upsertStatus($task->id, $swId, $pa->status);
+                        if ($r['action'] !== 'NOOP' && $r['after'] !== null) {
+                            $statusCells++;
+                            $this->audit->recordForRequest(
+                                action:     $r['action'] === 'CREATE' ? 'CREATE' : 'UPDATE',
+                                entityType: 'task_assignment',
+                                entityId:   $r['after']->id,
+                                before:     $r['before']?->toAuditSnapshot(),
+                                after:      $r['after']->toAuditSnapshot(),
+                                req:        $req,
+                                actor:      $actor,
+                            );
+                        }
+                    }
+                }
+            }
+
+            $this->pdo->commit();
+
+            return new ImportResult(
+                sprintId:             $sprint->id,
+                sprintName:           $sprint->name,
+                weekCount:            count($sheet->weeks),
+                workerCount:          count($sheet->workers),
+                taskCount:            count($sheet->tasks),
+                assignmentCellCount:  $assignmentCells,
+                statusCellCount:      $statusCells,
+                createdWorkers:       $createdWorkers,
+                missingOwners:        array_values(array_unique($missingOwners)),
+            );
+        } catch (Throwable $e) {
+            if ($this->pdo->inTransaction()) {
+                $this->pdo->rollBack();
+            }
+            throw $e;
+        }
+    }
+
+    private function isSprintEmpty(int $sprintId): bool
+    {
+        $rows = function (string $sql, array $args): int {
+            $stmt = $this->pdo->prepare($sql);
+            $stmt->execute($args);
+            return (int) $stmt->fetchColumn();
+        };
+        $w = $rows('SELECT COUNT(*) FROM sprint_weeks   WHERE sprint_id = ?', [$sprintId]);
+        $sw = $rows('SELECT COUNT(*) FROM sprint_workers WHERE sprint_id = ?', [$sprintId]);
+        $t = $rows('SELECT COUNT(*) FROM tasks          WHERE sprint_id = ?', [$sprintId]);
+        return $w === 0 && $sw === 0 && $t === 0;
+    }
+
+    /** @return array<string, \App\Domain\Worker> case-folded name → Worker */
+    private function buildWorkerLookup(): array
+    {
+        $out = [];
+        foreach ($this->workers->all() as $w) {
+            $out[self::fold($w->name)] = $w;
+        }
+        return $out;
+    }
+
+    /** Case-fold + collapse internal whitespace for robust name matching. */
+    public static function fold(string $name): string
+    {
+        $s = trim($name);
+        $s = preg_replace('/\s+/u', ' ', $s) ?? $s;
+        return mb_strtolower($s);
+    }
+
+    /**
+     * Map an integer max-working-days into a 5-bit Mo..Fr active_days_mask.
+     * Defaults to "first N weekdays set" — equivalent to (1 << N) - 1, capped
+     * to 31 (full Mo..Fr). The user can refine the mask afterwards in Sprint
+     * Settings.
+     */
+    public static function maxDaysToMask(int $maxDays): int
+    {
+        if ($maxDays <= 0) {
+            return 0;
+        }
+        if ($maxDays >= 5) {
+            return SprintWeek::MASK_ALL;
+        }
+        return (1 << $maxDays) - 1;
+    }
+}

+ 127 - 0
src/Services/Import/XlsxColorClassifier.php

@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Import;
+
+use App\Domain\TaskAssignment;
+
+/**
+ * Map an Excel cell fill colour (ARGB hex string, e.g. "FF00B050") to a
+ * task-assignment status. Pure: no dependencies, no I/O.
+ *
+ * Strategy:
+ *  - Strip the alpha; treat fully-transparent (alpha=00) as "no fill" → zugewiesen.
+ *  - Convert RGB → HSL.
+ *  - Bucket by hue + saturation + lightness.
+ *
+ * Buckets (cf. Phase 20 plan):
+ *  - greenish   (H 80..170, S > 0.15) → abgeschlossen
+ *  - yellowish  (H 30..80,  S > 0.15) → gestartet (covers Excel orange too)
+ *  - reddish    (H 340..360 or 0..20, S > 0.20) → abgebrochen
+ *  - everything else (white, light-blue, theme defaults, grey, dark) → zugewiesen
+ *
+ * Cells with a near-white/desaturated colour are treated as "no status" so
+ * the workbook's own background-banding doesn't bleed through as a status
+ * tag. The same applies to nulls and to Excel theme colours we can't
+ * resolve (the parser passes null in that case).
+ */
+final class XlsxColorClassifier
+{
+    /**
+     * @param string|null $argb e.g. "FF00B050", "FFFFFF00", or null when no
+     *                          user-applied colour exists. Case-insensitive.
+     *                          Accepts 6- or 8-character hex (alpha optional).
+     */
+    public static function classify(?string $argb): string
+    {
+        if ($argb === null) {
+            return TaskAssignment::STATUS_ZUGEWIESEN;
+        }
+        $hex = strtoupper(trim($argb));
+        if ($hex === '') {
+            return TaskAssignment::STATUS_ZUGEWIESEN;
+        }
+        if (str_starts_with($hex, '#')) {
+            $hex = substr($hex, 1);
+        }
+        if (strlen($hex) === 8) {
+            $alpha = (int) hexdec(substr($hex, 0, 2));
+            if ($alpha === 0) {
+                return TaskAssignment::STATUS_ZUGEWIESEN;
+            }
+            $rgb = substr($hex, 2);
+        } elseif (strlen($hex) === 6) {
+            $rgb = $hex;
+        } else {
+            return TaskAssignment::STATUS_ZUGEWIESEN;
+        }
+        if (!ctype_xdigit($rgb)) {
+            return TaskAssignment::STATUS_ZUGEWIESEN;
+        }
+
+        $r = (int) hexdec(substr($rgb, 0, 2));
+        $g = (int) hexdec(substr($rgb, 2, 2));
+        $b = (int) hexdec(substr($rgb, 4, 2));
+
+        [$h, $s, $l] = self::rgbToHsl($r, $g, $b);
+
+        // Near-white / very light: treat as "no status" (covers theme-tint default banding).
+        if ($l > 0.96 && $s < 0.20) {
+            return TaskAssignment::STATUS_ZUGEWIESEN;
+        }
+        // Desaturated → grey/black/banner — also no status.
+        if ($s < 0.10) {
+            return TaskAssignment::STATUS_ZUGEWIESEN;
+        }
+
+        // Reddish — both ends of the hue circle.
+        if (($h >= 340 || $h < 20) && $s > 0.20) {
+            return TaskAssignment::STATUS_ABGEBROCHEN;
+        }
+        // Yellow / orange: a wider band so Excel orange (FFC000, hue ~38) still
+        // counts and pale yellows like FFEB9C (hue ~48) do too.
+        if ($h >= 20 && $h < 80) {
+            return TaskAssignment::STATUS_GESTARTET;
+        }
+        // Greens.
+        if ($h >= 80 && $h < 170 && $s > 0.15) {
+            return TaskAssignment::STATUS_ABGESCHLOSSEN;
+        }
+
+        // Blues / teal / purple etc.: zugewiesen (default, transparent).
+        return TaskAssignment::STATUS_ZUGEWIESEN;
+    }
+
+    /**
+     * Convert sRGB (0..255 ints) to HSL (H in [0,360), S/L in [0,1]).
+     *
+     * @return array{0:float,1:float,2:float} [h, s, l]
+     */
+    public static function rgbToHsl(int $r, int $g, int $b): array
+    {
+        $rf = $r / 255.0;
+        $gf = $g / 255.0;
+        $bf = $b / 255.0;
+        $max = max($rf, $gf, $bf);
+        $min = min($rf, $gf, $bf);
+        $l = ($max + $min) / 2.0;
+
+        if ($max === $min) {
+            return [0.0, 0.0, $l];
+        }
+
+        $d = $max - $min;
+        $s = $l > 0.5 ? $d / (2.0 - $max - $min) : $d / ($max + $min);
+
+        if ($max === $rf) {
+            $h = (($gf - $bf) / $d) + ($gf < $bf ? 6.0 : 0.0);
+        } elseif ($max === $gf) {
+            $h = (($bf - $rf) / $d) + 2.0;
+        } else {
+            $h = (($rf - $gf) / $d) + 4.0;
+        }
+        $h *= 60.0;
+        return [$h, $s, $l];
+    }
+}

+ 415 - 0
src/Services/Import/XlsxSprintImporter.php

@@ -0,0 +1,415 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services\Import;
+
+use App\Domain\Import\ParsedAssignment;
+use App\Domain\Import\ParsedSheet;
+use App\Domain\Import\ParsedTask;
+use App\Domain\Import\ParsedWeek;
+use App\Domain\Import\ParsedWorker;
+use DateTimeImmutable;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use RuntimeException;
+
+/**
+ * Parse the team's "Tool_Sprint Planning" workbook into ParsedSheet[].
+ *
+ * Layout assumptions (locked down from doc/Tool_Sprint Planning.xlsx — see
+ * SPEC.md §9 Phase 20 for the full coordinate map):
+ *
+ *   Arbeitstage block (left):
+ *     C6 = "Arbeitstage", E6.. = max working days per week
+ *     C7 = "Datum",       E7.. = week-start date labels
+ *     C8 = "KW",          E8.. = ISO calendar-week numbers
+ *     C9..Cn = worker names; E.. days/week per worker; J = RTB; K = Σ
+ *     The "Reserven" row sits below the worker block: J{r} = "Reserven", K{r} = fraction.
+ *
+ *   Tasks block (right, starts at column M):
+ *     Q4..  worker-name FORMULAS (=C9, =C10, …) — authoritative task-column
+ *           → Arbeitstage worker mapping (skips Arbeitstage gaps).
+ *     Row 9 = display header (M="To Do", N="Owner", O="Prio", P="Tot",
+ *           Q.. = worker labels, sometimes drifted from row 4's formula
+ *           result — we trust row 4).
+ *     Row 10..n: M=title, N=owner, O=priority, P=Σ, Q.. days+colour.
+ *
+ * Year inference: the workbook only stores DD.MM in row 7, so we derive the
+ * sprint start date from KW (row 8) + the year whose corresponding ISO-week
+ * Monday is closest to today (looking ±1 year). The wizard preview lets the
+ * operator override the inferred start date before commit.
+ */
+final class XlsxSprintImporter
+{
+    private const MAX_WORKER_ROWS = 30;
+    private const MAX_TASK_ROWS   = 200;
+    private const MAX_WEEK_COLS   = 12;
+    private const MAX_TASK_COLS   = 40;
+
+    private const FIRST_WEEK_COL = 'E';   // E.. holds week values
+    private const FIRST_WORKER_ROW = 9;
+    private const RTB_COL          = 'J';
+    private const RESERVEN_VALUE_COL = 'K';
+    private const TASK_TITLE_COL = 'M';
+    private const TASK_OWNER_COL = 'N';
+    private const TASK_PRIO_COL  = 'O';
+    private const FIRST_TASK_COL = 'Q';
+    private const FIRST_TASK_ROW = 10;
+    private const HEADER_ROW_FOR_TASK_COLS = 4;
+    private const HEADER_ROW_LITERAL = 9;
+
+    /**
+     * @return list<ParsedSheet>
+     * @throws RuntimeException on unreadable file or empty workbook
+     */
+    public function parse(string $filePath): array
+    {
+        if (!is_file($filePath) || !is_readable($filePath)) {
+            throw new RuntimeException("Cannot read file: {$filePath}");
+        }
+
+        try {
+            $reader = IOFactory::createReader('Xlsx');
+            // We need cell styles to read fill colours; loadAllSheets default.
+            $reader->setReadDataOnly(false);
+            $reader->setIncludeCharts(false);
+            $book = $reader->load($filePath);
+        } catch (ReaderException $e) {
+            throw new RuntimeException('Not a readable XLSX: ' . $e->getMessage(), 0, $e);
+        }
+
+        $out = [];
+        foreach ($book->getWorksheetIterator() as $ws) {
+            $out[] = $this->parseSheet($ws);
+        }
+
+        if ($out === []) {
+            throw new RuntimeException('Workbook has no sheets');
+        }
+        return $out;
+    }
+
+    private function parseSheet(Worksheet $ws): ParsedSheet
+    {
+        $warnings = [];
+
+        // 1. Discover the week columns: E6.., stop at first empty cell.
+        $weekCols = $this->scanRowColumns($ws, 6, self::FIRST_WEEK_COL, self::MAX_WEEK_COLS);
+        // 2. Build ParsedWeek list, pulling max_working_days from row 6, KW from row 8, label from row 7.
+        $weeks = [];
+        $weekColIndex = [];
+        $sortOrder = 1;
+        foreach ($weekCols as $col) {
+            $maxDaysRaw = self::numericOrNull($ws->getCell($col . '6')->getValue());
+            $kwRaw      = self::numericOrNull($ws->getCell($col . '8')->getValue());
+            $labelRaw   = $ws->getCell($col . '7')->getValue();
+            $maxDays    = $maxDaysRaw === null ? 0 : (int) round($maxDaysRaw);
+            $kw         = $kwRaw === null ? 0 : (int) round($kwRaw);
+            $label      = is_scalar($labelRaw) ? (string) $labelRaw : '';
+            if ($maxDays > 5) {
+                $maxDays = 5;
+                $warnings[] = "Week {$sortOrder}: max_working_days clamped to 5.";
+            }
+            if ($maxDays < 0) {
+                $maxDays = 0;
+            }
+            $weeks[] = new ParsedWeek(
+                sortOrder:         $sortOrder,
+                kw:                $kw,
+                dateLabel:         $label,
+                maxWorkingDays:    $maxDays,
+                inferredStartDate: $kw > 0 ? $this->isoWeekToDate($kw) : null,
+            );
+            $weekColIndex[$sortOrder] = $col;
+            $sortOrder++;
+        }
+
+        // Sprint-level start/end inferred from the first/last weeks.
+        [$startDate, $endDate] = $this->inferSprintRange($weeks);
+
+        // 3. Walk worker rows starting at row 9. Stop at the Reserven row, or
+        //    when col C has been empty for more than GAP_TOLERANCE consecutive
+        //    rows (the sample workbook leaves a blank row between Titus and
+        //    Suzan in Sprint 2 — we tolerate that without truncating).
+        $reserveFraction = 0.2;
+        $reserveSeen     = false;
+        $workers         = [];
+        $consecutiveEmpty = 0;
+        $gapTolerance     = 2;
+        for ($i = 0; $i < self::MAX_WORKER_ROWS; $i++) {
+            $r = self::FIRST_WORKER_ROW + $i;
+            $cName = $ws->getCell('C' . $r)->getValue();
+            $jVal  = $ws->getCell(self::RTB_COL . $r)->getValue();
+
+            if (is_string($jVal) && trim($jVal) !== '' && self::ciEquals($jVal, 'Reserven')) {
+                $reserveSeen = true;
+                $kVal = self::numericOrNull($ws->getCell(self::RESERVEN_VALUE_COL . $r)->getValue());
+                if ($kVal !== null) {
+                    $reserveFraction = max(0.0, min(1.0, (float) $kVal));
+                }
+                break; // worker block ends at Reserven
+            }
+
+            $name = is_scalar($cName) ? trim((string) $cName) : '';
+            if ($name === '') {
+                $consecutiveEmpty++;
+                if ($consecutiveEmpty > $gapTolerance && $workers !== []) {
+                    break;
+                }
+                continue;
+            }
+            $consecutiveEmpty = 0;
+
+            $daysPerWeek = [];
+            foreach ($weekColIndex as $sortIdx => $col) {
+                $v = self::numericOrNull($ws->getCell($col . $r)->getValue());
+                $daysPerWeek[$sortIdx] = $v === null ? 0.0 : (float) $v;
+            }
+            $rtb = self::numericOrNull($ws->getCell(self::RTB_COL . $r)->getValue());
+            $workers[] = new ParsedWorker(
+                name:        $name,
+                daysPerWeek: $daysPerWeek,
+                rtb:         $rtb === null ? 0.0 : max(0.0, min(1.0, (float) $rtb)),
+            );
+        }
+
+        // 4. Build task-column → worker-name map from row 4 formulas (preferring
+        //    the cached calculated value; fall back to the row-9 literal).
+        $taskCols = $this->scanRowColumns($ws, self::HEADER_ROW_FOR_TASK_COLS, self::FIRST_TASK_COL, self::MAX_TASK_COLS);
+        $taskColToWorker = [];
+        foreach ($taskCols as $col) {
+            $name = $this->resolveTaskColumnWorker(
+                $ws,
+                $col,
+                self::HEADER_ROW_FOR_TASK_COLS,
+                self::HEADER_ROW_LITERAL,
+            );
+            if ($name !== null && $name !== '') {
+                $taskColToWorker[$col] = $name;
+            }
+        }
+        if ($taskColToWorker === []) {
+            $warnings[] = 'No task-block worker columns detected; the Tasks header at row 4 looks empty.';
+        }
+
+        // 5. Walk task rows from row 10 down. Tolerate up to GAP_TOLERANCE
+        //    consecutive empty title cells before declaring the block done —
+        //    Sprint 2's row 13 is a deliberate visual gap between two task
+        //    groups in the source workbook.
+        $tasks = [];
+        $consecutiveEmptyTaskRows = 0;
+        $taskGapTolerance = 2;
+        for ($i = 0; $i < self::MAX_TASK_ROWS; $i++) {
+            $r = self::FIRST_TASK_ROW + $i;
+            $title = $ws->getCell(self::TASK_TITLE_COL . $r)->getValue();
+            $title = is_scalar($title) ? trim((string) $title) : '';
+            if ($title === '') {
+                $consecutiveEmptyTaskRows++;
+                if ($consecutiveEmptyTaskRows > $taskGapTolerance) {
+                    break;
+                }
+                continue;
+            }
+            $consecutiveEmptyTaskRows = 0;
+
+            $owner = $ws->getCell(self::TASK_OWNER_COL . $r)->getValue();
+            $owner = is_scalar($owner) && trim((string) $owner) !== '' ? trim((string) $owner) : null;
+            $prioRaw = self::numericOrNull($ws->getCell(self::TASK_PRIO_COL . $r)->getValue());
+            $prio = $prioRaw === null ? 2 : (int) round($prioRaw);
+            if ($prio !== 1 && $prio !== 2) {
+                // Default missing or unrecognised priorities to "nice to have" (2).
+                $warnings[] = "Task '{$title}': priority '{$prio}' coerced to 2.";
+                $prio = 2;
+            }
+
+            $assignments = [];
+            foreach ($taskColToWorker as $col => $workerName) {
+                $cell = $ws->getCell($col . $r);
+                $val  = self::numericOrNull($cell->getValue());
+                $argb = $this->cellFillArgb($cell);
+                $status = XlsxColorClassifier::classify($argb);
+                $days = $val === null ? 0.0 : (float) $val;
+                if ($days > 0 || $status !== \App\Domain\TaskAssignment::STATUS_ZUGEWIESEN) {
+                    $assignments[] = new ParsedAssignment(
+                        workerName: $workerName,
+                        days:       max(0.0, $days),
+                        status:     $status,
+                        argbHex:    $argb,
+                    );
+                }
+            }
+            $tasks[] = new ParsedTask(
+                title:       $title,
+                ownerName:   $owner,
+                priority:    $prio,
+                assignments: $assignments,
+            );
+        }
+
+        return new ParsedSheet(
+            sheetName:         $ws->getTitle(),
+            weeks:             $weeks,
+            workers:           $workers,
+            tasks:             $tasks,
+            reserveFraction:   $reserveFraction,
+            inferredStartDate: $startDate,
+            inferredEndDate:   $endDate,
+            warnings:          $warnings,
+        );
+    }
+
+    /**
+     * Scan a row leftward from $startCol; return the column letters that hold
+     * a non-null value, stopping at the first empty cell.
+     *
+     * @return list<string>
+     */
+    private function scanRowColumns(Worksheet $ws, int $row, string $startCol, int $maxCols): array
+    {
+        $out = [];
+        $colIdx = Coordinate::columnIndexFromString($startCol);
+        for ($i = 0; $i < $maxCols; $i++) {
+            $col = Coordinate::stringFromColumnIndex($colIdx + $i);
+            $v = $ws->getCell($col . $row)->getValue();
+            // Treat both null and empty-string as a sentinel for "block ends here",
+            // but allow a tiny gap of 1 (tolerant): if next col has a value, keep going.
+            if ($v === null || (is_string($v) && trim($v) === '')) {
+                // peek one ahead — gap tolerance of 1
+                $nextCol = Coordinate::stringFromColumnIndex($colIdx + $i + 1);
+                $next = $ws->getCell($nextCol . $row)->getValue();
+                if ($next === null || (is_string($next) && trim($next) === '')) {
+                    break;
+                }
+                continue;
+            }
+            $out[] = $col;
+        }
+        return $out;
+    }
+
+    /**
+     * Resolve a task-block worker column header. Row 4 carries =C{n} formulas
+     * whose cached value is the canonical worker name; if that is unavailable
+     * for any reason, fall back to the row-9 typed literal.
+     */
+    private function resolveTaskColumnWorker(
+        Worksheet $ws,
+        string $col,
+        int $formulaRow,
+        int $literalRow,
+    ): ?string {
+        $cellF = $ws->getCell($col . $formulaRow);
+        $cached = $cellF->getOldCalculatedValue();
+        if (is_scalar($cached) && trim((string) $cached) !== '') {
+            return trim((string) $cached);
+        }
+        $rawF = $cellF->getValue();
+        if (is_scalar($rawF) && !is_string($rawF) || (is_string($rawF) && !str_starts_with($rawF, '='))) {
+            // Plain literal in row 4.
+            $s = trim((string) $rawF);
+            if ($s !== '') {
+                return $s;
+            }
+        }
+        $litRaw = $ws->getCell($col . $literalRow)->getValue();
+        $lit = is_scalar($litRaw) ? trim((string) $litRaw) : '';
+        return $lit === '' ? null : $lit;
+    }
+
+    /**
+     * Read a cell's fill colour as ARGB (e.g. "FF00B050"). Returns null when
+     * the cell has no user-applied solid fill (theme defaults, no fill, or
+     * non-solid pattern) so the colour classifier treats it as "no status".
+     */
+    private function cellFillArgb($cell): ?string
+    {
+        $style = $cell->getStyle();
+        $fill  = $style->getFill();
+        if ($fill->getFillType() !== Fill::FILL_SOLID) {
+            return null;
+        }
+        $color = $fill->getStartColor();
+        $argb  = $color->getARGB();
+        if (!is_string($argb) || $argb === '' || $argb === '00000000') {
+            return null;
+        }
+        // Theme-derived fills round-trip through getARGB() as the resolved RGB,
+        // but workbook-chrome theme colours (banner / header bands) end up
+        // saturation-low or near-white, which the classifier already filters.
+        return strtoupper($argb);
+    }
+
+    /**
+     * Pick the year whose ISO-week Monday for $kw is closest to today.
+     * Returns Y-m-d.
+     */
+    private function isoWeekToDate(int $kw, ?DateTimeImmutable $today = null): string
+    {
+        $today ??= new DateTimeImmutable('today');
+        $cy = (int) $today->format('o'); // ISO week-numbering year
+        $best = null;
+        $bestDist = PHP_INT_MAX;
+        foreach ([$cy - 1, $cy, $cy + 1] as $y) {
+            try {
+                $d = (new DateTimeImmutable())->setISODate($y, $kw, 1)->setTime(0, 0);
+            } catch (\Throwable) {
+                continue;
+            }
+            $dist = abs((int) $today->diff($d)->format('%r%a'));
+            if ($dist < $bestDist) {
+                $bestDist = $dist;
+                $best = $d;
+            }
+        }
+        return ($best ?? $today)->format('Y-m-d');
+    }
+
+    /**
+     * Sprint-level start (Monday of first week) and end (Sunday of last week).
+     *
+     * @param list<ParsedWeek> $weeks
+     * @return array{?string,?string}
+     */
+    private function inferSprintRange(array $weeks): array
+    {
+        if ($weeks === []) {
+            return [null, null];
+        }
+        $first = $weeks[0];
+        $last  = $weeks[count($weeks) - 1];
+        if ($first->inferredStartDate === null || $last->inferredStartDate === null) {
+            return [null, null];
+        }
+        $startD = DateTimeImmutable::createFromFormat('Y-m-d', $first->inferredStartDate);
+        $lastD  = DateTimeImmutable::createFromFormat('Y-m-d', $last->inferredStartDate);
+        if ($startD === false || $lastD === false) {
+            return [null, null];
+        }
+        $endD = $lastD->modify('+6 days');
+        return [$startD->format('Y-m-d'), $endD->format('Y-m-d')];
+    }
+
+    private static function numericOrNull(mixed $v): ?float
+    {
+        if (is_int($v) || is_float($v)) {
+            return (float) $v;
+        }
+        if (is_string($v) && $v !== '') {
+            $t = trim($v);
+            if (is_numeric($t)) {
+                return (float) $t;
+            }
+        }
+        return null;
+    }
+
+    private static function ciEquals(string $a, string $b): bool
+    {
+        return strcasecmp(trim($a), trim($b)) === 0;
+    }
+}

+ 73 - 0
tests/Controllers/ImportControllerTest.php

@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Controllers;
+
+use App\Controllers\ImportController;
+use App\Tests\TestCase;
+use ReflectionMethod;
+
+/**
+ * Phase 20 — pure-static guards on ImportController. Mirrors the
+ * UserControllerTest pattern: exercise the bits that do not need PDO or
+ * session wiring.
+ */
+final class ImportControllerTest extends TestCase
+{
+    public function testLooksLikeXlsxAcceptsRealZipHeader(): void
+    {
+        $tmp = tempnam(sys_get_temp_dir(), 'sptest');
+        file_put_contents($tmp, "PK\x03\x04rest of the file");
+        try {
+            $this->assertTrue(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
+            $this->assertFalse(self::call('looksLikeXlsx', 'workbook.txt',  $tmp), 'wrong extension');
+        } finally {
+            unlink($tmp);
+        }
+    }
+
+    public function testLooksLikeXlsxRejectsNonZip(): void
+    {
+        $tmp = tempnam(sys_get_temp_dir(), 'sptest');
+        file_put_contents($tmp, 'not a zip');
+        try {
+            $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
+        } finally {
+            unlink($tmp);
+        }
+    }
+
+    public function testLooksLikeXlsxRejectsTooShortFile(): void
+    {
+        $tmp = tempnam(sys_get_temp_dir(), 'sptest');
+        file_put_contents($tmp, 'PK');
+        try {
+            $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
+        } finally {
+            unlink($tmp);
+        }
+    }
+
+    public function testUploadErrorCodeMapping(): void
+    {
+        $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_INI_SIZE));
+        $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_FORM_SIZE));
+        $this->assertSame('partial', self::call('uploadErrorCode', UPLOAD_ERR_PARTIAL));
+        $this->assertSame('no_file', self::call('uploadErrorCode', UPLOAD_ERR_NO_FILE));
+        $this->assertSame('server',  self::call('uploadErrorCode', UPLOAD_ERR_NO_TMP_DIR));
+        $this->assertSame('server',  self::call('uploadErrorCode', UPLOAD_ERR_CANT_WRITE));
+        $this->assertSame('unknown', self::call('uploadErrorCode', 9999));
+    }
+
+    /**
+     * Reflectively call a private static helper on ImportController so we
+     * don't need to expand its public surface for testability.
+     */
+    private static function call(string $method, mixed ...$args): mixed
+    {
+        $r = new ReflectionMethod(ImportController::class, $method);
+        $r->setAccessible(true);
+        return $r->invoke(null, ...$args);
+    }
+}

+ 230 - 0
tests/Services/Import/SprintImporterCommitTest.php

@@ -0,0 +1,230 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Services\Import;
+
+use App\Domain\Import\ParsedAssignment;
+use App\Domain\Import\ParsedSheet;
+use App\Domain\Import\ParsedTask;
+use App\Domain\Import\ParsedWeek;
+use App\Domain\Import\ParsedWorker;
+use App\Domain\TaskAssignment;
+use App\Http\Request;
+use App\Repositories\SprintRepository;
+use App\Repositories\SprintWeekRepository;
+use App\Repositories\SprintWorkerDayRepository;
+use App\Repositories\SprintWorkerRepository;
+use App\Repositories\TaskAssignmentRepository;
+use App\Repositories\TaskRepository;
+use App\Repositories\WorkerRepository;
+use App\Services\AuditLogger;
+use App\Services\Import\SprintImporter;
+use App\Tests\TestCase;
+use PDO;
+use RuntimeException;
+
+/**
+ * Phase 20 — commit-side test. Builds a ParsedSheet by hand (no XLSX, no
+ * PhpSpreadsheet) and asserts that committing it produces the expected
+ * sprint, weeks, workers, sprint_workers, sprint_worker_days, tasks,
+ * task_assignments, and audit rows.
+ */
+final class SprintImporterCommitTest extends TestCase
+{
+    /**
+     * @return array{0:PDO,1:SprintImporter,2:Request}
+     */
+    private function build(): array
+    {
+        $pdo = $this->makeDb();
+        $importer = new SprintImporter(
+            pdo:          $pdo,
+            sprints:      new SprintRepository($pdo),
+            weeks:        new SprintWeekRepository($pdo),
+            sprintWorkers:new SprintWorkerRepository($pdo),
+            days:         new SprintWorkerDayRepository($pdo),
+            tasks:        new TaskRepository($pdo),
+            assignments:  new TaskAssignmentRepository($pdo),
+            workers:      new WorkerRepository($pdo),
+            audit:        new AuditLogger($pdo),
+        );
+        $req = new Request('POST', '/sprints/import/x', [], [], '', [], []);
+        return [$pdo, $importer, $req];
+    }
+
+    private function sampleSheet(): ParsedSheet
+    {
+        return new ParsedSheet(
+            sheetName: 'Sprint 1',
+            weeks: [
+                new ParsedWeek(1, 13, '23.03', 2, '2026-03-23'),
+                new ParsedWeek(2, 14, '30.03', 4, '2026-03-30'),
+            ],
+            workers: [
+                new ParsedWorker('Alice',    [1 => 2.0, 2 => 4.0], 0.5),
+                new ParsedWorker('Bob',      [1 => 1.0, 2 => 0.0], 0.7),
+            ],
+            tasks: [
+                new ParsedTask('Build thing',  'Alice', 1, [
+                    new ParsedAssignment('Alice', 2.0, TaskAssignment::STATUS_ABGESCHLOSSEN, 'FF00B050'),
+                    new ParsedAssignment('Bob',   1.0, TaskAssignment::STATUS_GESTARTET,    'FFFFFF00'),
+                ]),
+                new ParsedTask('Review thing', 'Carol', 2, [
+                    new ParsedAssignment('Alice', 0.5, TaskAssignment::STATUS_ZUGEWIESEN,   null),
+                ]),
+            ],
+            reserveFraction:   0.2,
+            inferredStartDate: '2026-03-23',
+            inferredEndDate:   '2026-04-05',
+            warnings:          [],
+        );
+    }
+
+    public function testCreateNewSprintWritesEverythingTransactionally(): void
+    {
+        [$pdo, $importer, $req] = $this->build();
+        $sheet = $this->sampleSheet();
+
+        $result = $importer->commit(
+            sheet:            $sheet,
+            sprintName:       'Sprint 1',
+            startDate:        '2026-03-23',
+            endDate:          '2026-04-05',
+            target:           'new',
+            existingSprintId: null,
+            req:              $req,
+            actor:            null,
+        );
+
+        $this->assertSame('Sprint 1', $result->sprintName);
+        $this->assertSame(2, $result->weekCount);
+        $this->assertSame(2, $result->workerCount);
+        $this->assertSame(2, $result->taskCount);
+
+        // The two parsed workers were both auto-created.
+        $this->assertSame(['Alice', 'Bob'], $result->createdWorkers);
+        // Carol (the second task's owner) doesn't exist as a worker — recorded as missing.
+        $this->assertSame(['Carol'], $result->missingOwners);
+
+        // Sprint row + reserve.
+        $sprintRow = $pdo->query('SELECT * FROM sprints')->fetch();
+        $this->assertSame('Sprint 1', $sprintRow['name']);
+        $this->assertSame('2026-03-23', $sprintRow['start_date']);
+        $this->assertEqualsWithDelta(0.2, (float) $sprintRow['reserve_fraction'], 1e-9);
+
+        // 2 weeks with active_days_mask matching maxDays.
+        $weeks = $pdo->query('SELECT * FROM sprint_weeks ORDER BY sort_order')->fetchAll();
+        $this->assertCount(2, $weeks);
+        $this->assertSame(0b00011, (int) $weeks[0]['active_days_mask'], '2 days → Mo+Di');
+        $this->assertSame(0b01111, (int) $weeks[1]['active_days_mask'], '4 days → Mo..Do');
+        $this->assertEqualsWithDelta(2.0, (float) $weeks[0]['max_working_days'], 1e-9);
+        $this->assertEqualsWithDelta(4.0, (float) $weeks[1]['max_working_days'], 1e-9);
+
+        // 2 sprint_workers with RTBs.
+        $sw = $pdo->query('SELECT sw.*, w.name FROM sprint_workers sw JOIN workers w ON w.id=sw.worker_id ORDER BY sw.sort_order')->fetchAll();
+        $this->assertCount(2, $sw);
+        $this->assertSame('Alice', $sw[0]['name']);
+        $this->assertEqualsWithDelta(0.5, (float) $sw[0]['rtb'], 1e-9);
+        $this->assertSame('Bob',   $sw[1]['name']);
+        $this->assertEqualsWithDelta(0.7, (float) $sw[1]['rtb'], 1e-9);
+
+        // sprint_worker_days: 3 non-zero cells (Alice w1+w2, Bob w1; Bob w2 was 0).
+        $dayCells = (int) $pdo->query('SELECT COUNT(*) FROM sprint_worker_days')->fetchColumn();
+        $this->assertSame(3, $dayCells);
+
+        // 2 tasks; first owned by Alice, second has no owner (Carol unknown).
+        $tasks = $pdo->query('SELECT * FROM tasks ORDER BY sort_order')->fetchAll();
+        $this->assertCount(2, $tasks);
+        $this->assertSame('Build thing',  $tasks[0]['title']);
+        $this->assertNotNull($tasks[0]['owner_worker_id']);
+        $this->assertSame('Review thing', $tasks[1]['title']);
+        $this->assertNull($tasks[1]['owner_worker_id']);
+        $this->assertSame(1, (int) $tasks[0]['priority']);
+        $this->assertSame(2, (int) $tasks[1]['priority']);
+
+        // Task assignments: 2 days+colour for task 1, none for task 2 (the
+        // single ZUGEWIESEN cell with 0.5 days writes a row but no status row).
+        $aRows = $pdo->query('SELECT ta.*, t.title FROM task_assignments ta JOIN tasks t ON t.id=ta.task_id ORDER BY t.sort_order, ta.sprint_worker_id')->fetchAll();
+        $this->assertCount(3, $aRows, 'three assignment rows total');
+
+        // Status: the green cell → abgeschlossen, the yellow cell → gestartet,
+        // the default zugewiesen cell stays at default.
+        $statuses = array_map(fn($r) => $r['status'], $aRows);
+        sort($statuses);
+        $this->assertSame(['abgeschlossen', 'gestartet', 'zugewiesen'], $statuses);
+
+        // Audit log: sprint CREATE + IMPORTED_FROM_XLSX + 2× sprint_week CREATE
+        // + 2× worker CREATE + 2× sprint_worker CREATE + 3× sprint_worker_day CREATE
+        // + 2× task CREATE + 3× task_assignment CREATE + 2× task_assignment status UPDATE/CREATE.
+        $auditCount = (int) $pdo->query('SELECT COUNT(*) FROM audit_log')->fetchColumn();
+        $this->assertGreaterThan(15, $auditCount, 'every write is audited');
+
+        $importedRows = (int) $pdo->query("SELECT COUNT(*) FROM audit_log WHERE action='IMPORTED_FROM_XLSX'")->fetchColumn();
+        $this->assertSame(1, $importedRows);
+    }
+
+    public function testRefusesToImportIntoNonEmptyExistingSprint(): void
+    {
+        [$pdo, $importer, $req] = $this->build();
+        $sprints = new SprintRepository($pdo);
+        $sprint = $sprints->create('Existing', '2026-03-23', '2026-04-05', 0.2);
+        $sprints->materializeWeeks($sprint->id, '2026-03-23', 2); // populates sprint_weeks
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('not empty');
+
+        $importer->commit(
+            sheet:            $this->sampleSheet(),
+            sprintName:       'Existing',
+            startDate:        '2026-03-23',
+            endDate:          '2026-04-05',
+            target:           'existing',
+            existingSprintId: $sprint->id,
+            req:              $req,
+            actor:            null,
+        );
+    }
+
+    public function testReusesExistingWorkerByCaseFoldedName(): void
+    {
+        [$pdo, $importer, $req] = $this->build();
+        // Pre-create Alice with non-matching case + extra spaces.
+        (new WorkerRepository($pdo))->create('  alice  ', true, 0.0);
+
+        $result = $importer->commit(
+            sheet:            $this->sampleSheet(),
+            sprintName:       'S',
+            startDate:        '2026-03-23',
+            endDate:          '2026-04-05',
+            target:           'new',
+            existingSprintId: null,
+            req:              $req,
+            actor:            null,
+        );
+
+        // Bob is created; Alice already existed (matched by case-folded name).
+        $this->assertSame(['Bob'], $result->createdWorkers);
+        $this->assertSame(2, (int) $pdo->query('SELECT COUNT(*) FROM workers')->fetchColumn());
+    }
+
+    public function testMaxDaysToMaskSemantics(): void
+    {
+        $this->assertSame(0,       SprintImporter::maxDaysToMask(0));
+        $this->assertSame(0b00001, SprintImporter::maxDaysToMask(1));
+        $this->assertSame(0b00011, SprintImporter::maxDaysToMask(2));
+        $this->assertSame(0b00111, SprintImporter::maxDaysToMask(3));
+        $this->assertSame(0b01111, SprintImporter::maxDaysToMask(4));
+        $this->assertSame(0b11111, SprintImporter::maxDaysToMask(5));
+        $this->assertSame(0b11111, SprintImporter::maxDaysToMask(7), 'caps at 5');
+        $this->assertSame(0,       SprintImporter::maxDaysToMask(-1));
+    }
+
+    public function testFoldNormalisation(): void
+    {
+        $this->assertSame('alice', SprintImporter::fold('Alice'));
+        $this->assertSame('alice', SprintImporter::fold('  ALICE  '));
+        $this->assertSame('michael br', SprintImporter::fold('Michael  Br'));
+        $this->assertSame('jürg', SprintImporter::fold('Jürg'));
+    }
+}

+ 58 - 0
tests/Services/Import/XlsxColorClassifierTest.php

@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Services\Import;
+
+use App\Domain\TaskAssignment;
+use App\Services\Import\XlsxColorClassifier;
+use App\Tests\TestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+
+/**
+ * Phase 20 — pure ARGB → status classifier. Table-driven against the
+ * colours observed in doc/Tool_Sprint Planning.xlsx plus a handful of
+ * synthetic edge cases.
+ */
+final class XlsxColorClassifierTest extends TestCase
+{
+    /** @return list<array{0:?string,1:string,2:string}> */
+    public static function cases(): array
+    {
+        return [
+            // Real cell colours present in the sample workbook.
+            ['FF00B050', TaskAssignment::STATUS_ABGESCHLOSSEN, 'Excel "Green"'],
+            ['FFC6EFCE', TaskAssignment::STATUS_ABGESCHLOSSEN, 'pastel green'],
+            ['FFFFFF00', TaskAssignment::STATUS_GESTARTET,     'pure yellow'],
+            ['FFFFEB9C', TaskAssignment::STATUS_GESTARTET,     'pale yellow'],
+            ['FFFFC000', TaskAssignment::STATUS_GESTARTET,     'Excel orange'],
+            ['FFC0E6F5', TaskAssignment::STATUS_ZUGEWIESEN,    'light blue'],
+
+            // Synthetic edges.
+            [null,        TaskAssignment::STATUS_ZUGEWIESEN,   'no fill'],
+            ['',          TaskAssignment::STATUS_ZUGEWIESEN,   'empty hex'],
+            ['00000000',  TaskAssignment::STATUS_ZUGEWIESEN,   'fully transparent'],
+            ['FFFFFFFF',  TaskAssignment::STATUS_ZUGEWIESEN,   'pure white'],
+            ['FF000000',  TaskAssignment::STATUS_ZUGEWIESEN,   'pure black (low S)'],
+            ['FFFF0000',  TaskAssignment::STATUS_ABGEBROCHEN,  'pure red'],
+            ['FFC00000',  TaskAssignment::STATUS_ABGEBROCHEN,  'dark red'],
+            ['FFFFC7CE',  TaskAssignment::STATUS_ABGEBROCHEN,  'Excel "bad" red'],
+            ['FF0070C0',  TaskAssignment::STATUS_ZUGEWIESEN,   'Excel blue'],
+            ['FFB4A7D6',  TaskAssignment::STATUS_ZUGEWIESEN,   'lavender'],
+
+            // 6-char (no alpha) accepted.
+            ['00B050',    TaskAssignment::STATUS_ABGESCHLOSSEN, '6-char green'],
+            ['#FFFF00',   TaskAssignment::STATUS_GESTARTET,    'leading hash'],
+
+            // Garbage → fallback to default.
+            ['XYZ',       TaskAssignment::STATUS_ZUGEWIESEN,   'garbage'],
+            ['FF12',      TaskAssignment::STATUS_ZUGEWIESEN,   'short hex'],
+        ];
+    }
+
+    #[DataProvider('cases')]
+    public function testClassify(?string $argb, string $expected, string $label): void
+    {
+        $this->assertSame($expected, XlsxColorClassifier::classify($argb), $label);
+    }
+}

+ 117 - 0
tests/Services/Import/XlsxSprintImporterTest.php

@@ -0,0 +1,117 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Services\Import;
+
+use App\Domain\Import\ParsedSheet;
+use App\Domain\TaskAssignment;
+use App\Services\Import\XlsxSprintImporter;
+use App\Tests\TestCase;
+
+/**
+ * Phase 20 — parser smoke test against the real sample workbook
+ * (doc/Tool_Sprint Planning.xlsx). Skipped when the host PHP lacks the
+ * extensions PhpSpreadsheet needs (ext-dom, ext-zip etc.); the production
+ * Docker image and standard CI runners both have them.
+ */
+final class XlsxSprintImporterTest extends TestCase
+{
+    private const FIXTURE = __DIR__ . '/../../../doc/Tool_Sprint Planning.xlsx';
+
+    protected function setUp(): void
+    {
+        foreach (['dom', 'zip', 'xmlreader', 'simplexml', 'gd'] as $ext) {
+            if (!extension_loaded($ext)) {
+                $this->markTestSkipped("ext-{$ext} not loaded; PhpSpreadsheet cannot run on this host.");
+            }
+        }
+        if (!is_file(self::FIXTURE)) {
+            $this->markTestSkipped('Sample workbook not present at ' . self::FIXTURE);
+        }
+    }
+
+    public function testParsesEverySheet(): void
+    {
+        $parser = new XlsxSprintImporter();
+        $sheets = $parser->parse(self::FIXTURE);
+
+        $this->assertCount(3, $sheets);
+        $this->assertSame(['Sprint 1', 'Sprint 2', 'Sprint 3'], array_map(fn(ParsedSheet $s) => $s->sheetName, $sheets));
+    }
+
+    public function testSprint1ShapeAndCounts(): void
+    {
+        $parser = new XlsxSprintImporter();
+        $sheets = $parser->parse(self::FIXTURE);
+        $s = $sheets[0];
+
+        $this->assertSame(5, count($s->weeks), '5 weeks');
+        $this->assertSame(15, count($s->workers), '15 workers');
+        $this->assertGreaterThan(20, count($s->tasks), 'more than 20 tasks');
+
+        $this->assertEqualsWithDelta(0.2, $s->reserveFraction, 1e-9, 'reserve fraction = 0.2');
+
+        $kws = array_map(fn($w) => $w->kw, $s->weeks);
+        $this->assertSame([13, 14, 15, 16, 17], $kws, 'KWs 13..17 in order');
+
+        $maxDays = array_map(fn($w) => $w->maxWorkingDays, $s->weeks);
+        $this->assertSame([2, 4, 4, 5, 2], $maxDays);
+    }
+
+    public function testSprint2ColourMappingMatchesSpreadsheet(): void
+    {
+        $parser = new XlsxSprintImporter();
+        $sheets = $parser->parse(self::FIXTURE);
+        $s2 = $sheets[1];
+        $this->assertSame('Sprint 2', $s2->sheetName);
+
+        $statusCounts = [
+            TaskAssignment::STATUS_ZUGEWIESEN    => 0,
+            TaskAssignment::STATUS_GESTARTET     => 0,
+            TaskAssignment::STATUS_ABGESCHLOSSEN => 0,
+            TaskAssignment::STATUS_ABGEBROCHEN   => 0,
+        ];
+        foreach ($s2->tasks as $t) {
+            foreach ($t->assignments as $a) {
+                $statusCounts[$a->status]++;
+            }
+        }
+
+        // From the openpyxl colour audit on the sample:
+        //   17× FFFFFF00 + 6× FFFFEB9C + 4× FFFFC000 = 27 yellow/orange  -> gestartet
+        //    4× FF00B050 + 1× FFC6EFCE                = 5 green          -> abgeschlossen
+        //   the only red-coded cells in the workbook are zero.
+        $this->assertSame(27, $statusCounts[TaskAssignment::STATUS_GESTARTET], 'yellow + orange cells = 27');
+        $this->assertSame(5,  $statusCounts[TaskAssignment::STATUS_ABGESCHLOSSEN], 'green cells = 5');
+        $this->assertSame(0,  $statusCounts[TaskAssignment::STATUS_ABGEBROCHEN], 'no red cells in sample');
+    }
+
+    public function testSprint2SkipsArbeitstageGapAndDefinesSixteenWorkers(): void
+    {
+        // Sprint 2's Arbeitstage block has a blank row at C13 between Titus and
+        // Suzan; the parser should resume past the gap and end up with 16 workers.
+        $parser = new XlsxSprintImporter();
+        $sheets = $parser->parse(self::FIXTURE);
+        $s2 = $sheets[1];
+        $this->assertCount(16, $s2->workers, 'Sprint 2 has 16 workers (gap row tolerated)');
+
+        $names = array_map(fn($w) => $w->name, $s2->workers);
+        $this->assertContains('Suzan', $names);
+        $this->assertContains('Nicole', $names);
+    }
+
+    public function testRoundTripsViaToArrayFromArray(): void
+    {
+        $parser = new XlsxSprintImporter();
+        $sheets = $parser->parse(self::FIXTURE);
+        foreach ($sheets as $orig) {
+            $clone = ParsedSheet::fromArray($orig->toArray());
+            $this->assertSame($orig->sheetName, $clone->sheetName);
+            $this->assertSame(count($orig->weeks),   count($clone->weeks));
+            $this->assertSame(count($orig->workers), count($clone->workers));
+            $this->assertSame(count($orig->tasks),   count($clone->tasks));
+            $this->assertEqualsWithDelta($orig->reserveFraction, $clone->reserveFraction, 1e-9);
+        }
+    }
+}

+ 1 - 0
views/layout.twig

@@ -22,6 +22,7 @@
                     <a href="/" class="text-slate-600 hover:text-slate-900 hover:underline dark:text-slate-300 dark:hover:text-slate-100">Sprints</a>
                     {% if currentUser.isAdmin %}
                         <a href="/sprints/new" class="text-slate-600 hover:text-slate-900 hover:underline dark:text-slate-300 dark:hover:text-slate-100">New sprint</a>
+                        <a href="/sprints/import" class="text-slate-600 hover:text-slate-900 hover:underline dark:text-slate-300 dark:hover:text-slate-100">Import</a>
                     {% endif %}
                     <span class="text-slate-400 dark:text-slate-600">·</span>
                     <span class="text-slate-600 dark:text-slate-300">

+ 138 - 0
views/sprints/import_preview.twig

@@ -0,0 +1,138 @@
+{% extends "layout.twig" %}
+
+{% set errorMessages = {
+    'commit':           'A sheet failed to import; see the message below. Earlier sheets in the run did commit.',
+    'expired':          'Your previous import session expired. Please upload again.',
+    'nothing_selected': 'No sheets were selected to import.',
+} %}
+
+{% block content %}
+<section class="space-y-6">
+    <header class="flex items-baseline gap-4 flex-wrap">
+        <h1 class="text-2xl font-semibold tracking-tight">Import preview</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ fileName }}</span>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ sheets|length }} sheet(s) found</span>
+    </header>
+
+    {% if error != '' and errorMessages[error] is defined %}
+        <div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200">
+            {{ errorMessages[error] }}
+        </div>
+    {% endif %}
+
+    <form method="post" action="/sprints/import/{{ token }}" class="space-y-6">
+        <input type="hidden" name="_csrf" value="{{ csrfToken }}">
+
+        {% for sheet in sheets %}
+            {% set s = summaries[loop.index0] %}
+            {% set idx = loop.index0 %}
+            <section class="rounded-lg border bg-white p-5 dark:bg-slate-800 dark:border-slate-700">
+                <header class="flex items-baseline gap-3 flex-wrap">
+                    <h2 class="text-lg font-semibold">{{ sheet.sheetName }}</h2>
+                    <span class="text-xs text-slate-500 dark:text-slate-400">
+                        {{ s.weekCount }} weeks · {{ s.workerCount }} workers · {{ s.taskCount }} tasks · {{ s.cellCount }} day cells
+                    </span>
+                </header>
+
+                {% if sheet.warnings is not empty %}
+                    <ul class="mt-3 space-y-1 text-xs text-amber-700 dark:text-amber-300">
+                        {% for w in sheet.warnings %}<li>· {{ w }}</li>{% endfor %}
+                    </ul>
+                {% endif %}
+
+                <div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
+                    <label class="block">
+                        <span class="text-sm text-slate-700 dark:text-slate-300">Sprint name</span>
+                        <input type="text" name="name_{{ idx }}" value="{{ sheet.sheetName }}"
+                               class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+                    </label>
+
+                    <label class="block">
+                        <span class="text-sm text-slate-700 dark:text-slate-300">Reserve fraction (read-only — taken from sheet)</span>
+                        <input type="text" disabled value="{{ (sheet.reserveFraction * 100)|round(0) }}%"
+                               class="mt-1 block w-full rounded-md border-slate-300 border bg-slate-50 px-3 py-2 text-slate-500 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-400">
+                    </label>
+
+                    <label class="block">
+                        <span class="text-sm text-slate-700 dark:text-slate-300">Start date</span>
+                        <input type="date" name="start_{{ idx }}" value="{{ sheet.inferredStartDate }}"
+                               class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+                        <span class="text-xs text-slate-500 dark:text-slate-400">Inferred from KW {{ sheet.weeks[0].kw }}; check the year.</span>
+                    </label>
+                    <label class="block">
+                        <span class="text-sm text-slate-700 dark:text-slate-300">End date</span>
+                        <input type="date" name="end_{{ idx }}" value="{{ sheet.inferredEndDate }}"
+                               class="mt-1 block w-full rounded-md border-slate-300 border shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+                    </label>
+                </div>
+
+                <fieldset class="mt-5">
+                    <legend class="text-sm font-medium text-slate-700 dark:text-slate-300">Target sprint</legend>
+                    <div class="mt-2 space-y-1 text-sm">
+                        <label class="flex items-center gap-2">
+                            <input type="radio" name="target_{{ idx }}" value="new" checked>
+                            <span>Create new sprint</span>
+                        </label>
+                        <label class="flex items-center gap-2 {% if emptySprints is empty %}text-slate-400 dark:text-slate-600{% endif %}">
+                            <input type="radio" name="target_{{ idx }}" value="existing"
+                                   {% if emptySprints is empty %}disabled{% endif %}>
+                            <span>Merge into empty existing sprint:</span>
+                            <select name="existing_{{ idx }}"
+                                    {% if emptySprints is empty %}disabled{% endif %}
+                                    class="rounded-md border-slate-300 border px-2 py-1 text-sm dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100">
+                                <option value="">— pick —</option>
+                                {% for sp in emptySprints %}
+                                    <option value="{{ sp.id }}">{{ sp.name }} ({{ sp.startDate }} → {{ sp.endDate }})</option>
+                                {% endfor %}
+                            </select>
+                        </label>
+                    </div>
+                    {% if emptySprints is empty %}
+                        <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
+                            No empty sprints exist. Only sprints with no weeks, workers, or tasks can be merge targets.
+                        </p>
+                    {% endif %}
+                </fieldset>
+
+                <div class="mt-5 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
+                    <div class="rounded-md border border-slate-200 p-3 dark:border-slate-700">
+                        <div class="font-medium text-slate-700 dark:text-slate-300">Workers</div>
+                        {% if s.newWorkers is empty %}
+                            <p class="mt-1 text-slate-500 dark:text-slate-400">All {{ s.workerCount }} workers already exist.</p>
+                        {% else %}
+                            <p class="mt-1 text-slate-600 dark:text-slate-400">
+                                Will create {{ s.newWorkers|length }} new worker(s):
+                                <span class="text-slate-800 dark:text-slate-100">{{ s.newWorkers|join(', ') }}</span>
+                            </p>
+                        {% endif %}
+                    </div>
+                    <div class="rounded-md border border-slate-200 p-3 dark:border-slate-700">
+                        <div class="font-medium text-slate-700 dark:text-slate-300">Status colours</div>
+                        <ul class="mt-1 space-y-0.5 text-slate-600 dark:text-slate-400">
+                            <li><span class="inline-block w-3 h-3 align-middle rounded-sm bg-slate-300 dark:bg-slate-600 mr-1"></span> zugewiesen: {{ s.statusCounts.zugewiesen }}</li>
+                            <li><span class="inline-block w-3 h-3 align-middle rounded-sm bg-yellow-300 mr-1"></span> gestartet: {{ s.statusCounts.gestartet }}</li>
+                            <li><span class="inline-block w-3 h-3 align-middle rounded-sm bg-green-500 mr-1"></span> abgeschlossen: {{ s.statusCounts.abgeschlossen }}</li>
+                            <li><span class="inline-block w-3 h-3 align-middle rounded-sm bg-red-500 mr-1"></span> abgebrochen: {{ s.statusCounts.abgebrochen }}</li>
+                        </ul>
+                    </div>
+                </div>
+
+                <label class="mt-4 flex items-center gap-2 text-sm">
+                    <input type="checkbox" name="skip_{{ idx }}" value="1">
+                    <span>Skip this sheet</span>
+                </label>
+            </section>
+        {% endfor %}
+
+        <div class="flex gap-3">
+            <button type="submit"
+                    class="rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
+                Commit import
+            </button>
+            <a href="/sprints/import" class="inline-flex items-center rounded-md border border-slate-300 bg-white text-slate-700 px-4 py-2 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                Cancel
+            </a>
+        </div>
+    </form>
+</section>
+{% endblock %}

+ 65 - 0
views/sprints/import_upload.twig

@@ -0,0 +1,65 @@
+{% extends "layout.twig" %}
+
+{% set errorMessages = {
+    'no_file':         'No file was uploaded.',
+    'too_big':         'The file is larger than 5 MB.',
+    'partial':         'The upload was interrupted. Try again.',
+    'upload_invalid':  'Upload validation failed.',
+    'not_xlsx':        'That doesn’t look like an .xlsx file.',
+    'parse_failed':    'Could not parse the workbook. Open it in Excel to confirm it’s not corrupted.',
+    'expired':         'Your previous import session expired. Please upload again.',
+    'nothing_selected':'No sheets were selected to import.',
+    'server':          'Server upload error.',
+    'size':            'File size is invalid.',
+    'unknown':         'Upload failed.',
+} %}
+
+{% block content %}
+<section class="max-w-2xl">
+    <h1 class="text-2xl font-semibold tracking-tight">Import sprints from XLSX</h1>
+    <p class="text-slate-600 mt-1 text-sm dark:text-slate-400">
+        Upload the team’s <em>Tool_Sprint Planning</em> workbook. Each tab is parsed
+        as one sprint; the next step lets you confirm targets, names, dates,
+        and review the diff before committing.
+    </p>
+
+    {% if error != '' and errorMessages[error] is defined %}
+        <div class="mt-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:bg-red-900 dark:border-red-800 dark:text-red-200">
+            {{ errorMessages[error] }}
+        </div>
+    {% endif %}
+
+    <form method="post" action="/sprints/import" enctype="multipart/form-data"
+          class="mt-6 space-y-4 rounded-lg border bg-white p-5 dark:bg-slate-800 dark:border-slate-700">
+        <input type="hidden" name="_csrf" value="{{ csrfToken }}">
+
+        <label class="block">
+            <span class="text-sm text-slate-700 dark:text-slate-300">Workbook (.xlsx, max 5 MB)</span>
+            <input type="file" name="xlsx" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required
+                   class="mt-1 block w-full text-sm text-slate-700 file:mr-3 file:rounded-md file:border-0 file:bg-slate-900 file:text-white file:px-3 file:py-2 dark:text-slate-200 dark:file:bg-slate-700">
+        </label>
+
+        <details class="text-sm text-slate-600 dark:text-slate-400">
+            <summary class="cursor-pointer select-none">How are cell colours mapped to status?</summary>
+            <ul class="mt-2 ml-5 list-disc space-y-1">
+                <li>Greens (e.g. Excel “Green”, light pastel green) → <strong>abgeschlossen</strong>.</li>
+                <li>Yellows and oranges → <strong>gestartet</strong>.</li>
+                <li>Reds → <strong>abgebrochen</strong>.</li>
+                <li>White, blue, or no fill → <strong>zugewiesen</strong> (default).</li>
+            </ul>
+            <p class="mt-2">Workers and tasks are matched to existing rows by name (case-insensitive).
+            Anything not yet in the database is created.</p>
+        </details>
+
+        <div class="flex gap-3 pt-2">
+            <button type="submit"
+                    class="rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
+                Upload &amp; preview
+            </button>
+            <a href="/" class="inline-flex items-center rounded-md border border-slate-300 bg-white text-slate-700 px-4 py-2 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                Cancel
+            </a>
+        </div>
+    </form>
+</section>
+{% endblock %}