diff --git a/CONTEXT.md b/CONTEXT.md index 5ba537e3..ea4a5a69 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,3 +17,17 @@ _Avoid_: ParkingSpotConfirmation **ParkingRule**: A reference to parking rules for a municipality or an entire country. **Favorite**: A user's saved reference to a parking option from any of the three sources. + +**DatasetSource**: A dataset selected for connection to NIPKaart, with an identified publisher, scope and provenance. It is not a research lead or a contact-management record. + +**SourceRecord**: What one dataset says about a parking place or facility, identified within that dataset. Multiple source records can describe the same physical place without becoming the same source. + +**ParkingObservation**: A dated statement about particular properties of a parking place, with its method and supporting provenance. Its registration date does not establish when the place was observed. + +**CorrectionProposal**: A proposed change to information about an existing community or imported parking record, with a reason and supporting observation. + +**LocalCorrection**: An accepted correction whose value and justification remain distinct from the source's current statement. + +**PublicationDecision**: An attributable decision about which information NIPKaart presents, including the reason and evidence on which it is based. + +**ParkingRecordLink**: An assessed relationship between parking records, distinguishing records that describe the same place from a space located within a facility. diff --git a/app/Console/Commands/RegisterAmsterdamDataset.php b/app/Console/Commands/RegisterAmsterdamDataset.php new file mode 100644 index 00000000..49f09374 --- /dev/null +++ b/app/Console/Commands/RegisterAmsterdamDataset.php @@ -0,0 +1,37 @@ +find($this->argument('municipality')); + if (! $municipality || $municipality->name !== 'Amsterdam' || $municipality->country->code !== 'NL' || $municipality->province->geocode !== 'NL-NH') { + $this->error('Select Amsterdam in Noord-Holland, Netherlands.'); + + return self::FAILURE; + } + DatasetSource::firstOrCreate(['code' => 'nl-amsterdam-parkeervakken-e6a'], [ + 'name' => 'Amsterdam — algemene gehandicaptenparkeerplaatsen', + 'selection' => 'e6a-all', 'target_type' => 'municipal', + 'source_url' => 'https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken/', + 'attribution' => 'Gemeente Amsterdam; parkeervakken E6a; capaciteit is een schatting.', + 'terms_url' => 'https://data.overheid.nl/dataset/318a98b8-ef87-4335-9674-f5405f2bc4be', + 'municipality_id' => $municipality->id, + 'bounds' => [4.65, 52.2, 5.15, 52.5], + 'publication_enabled' => false, + ]); + $this->info('Dataset registered. Confirm the source terms in the import screen before publication.'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/MunicipalImportController.php b/app/Http/Controllers/Admin/MunicipalImportController.php new file mode 100644 index 00000000..551ebfc2 --- /dev/null +++ b/app/Http/Controllers/Admin/MunicipalImportController.php @@ -0,0 +1,72 @@ + DatasetSource::orderBy('name')->get(), + 'imports' => MunicipalImport::with('datasetSource:id,name')->latest('id')->paginate(20), + ]); + } + + public function store(StoreMunicipalImportRequest $request, MunicipalImportService $service): RedirectResponse + { + $import = $service->intake($request->file('file')->getContent(), $request->user()); + + return to_route('app.municipal-imports.show', $import); + } + + public function show(Request $request, MunicipalImport $municipalImport, MunicipalImportService $service): Response + { + Gate::authorize('view', $municipalImport); + $review = $service->review($municipalImport); + $page = max(1, min((int) $request->query('page', 1), max(1, (int) ceil(count($review['rows']) / 50)))); + $total = count($review['rows']); + $review['rows'] = array_slice($review['rows'], ($page - 1) * 50, 50); + + return Inertia::render('backend/municipal-imports/show', [ + 'import' => $municipalImport, 'dataset' => $municipalImport->datasetSource, + 'review' => $review, 'page' => $page, 'pages' => max(1, (int) ceil($total / 50)), + ]); + } + + public function update(Request $request, MunicipalImport $municipalImport, MunicipalImportService $service): RedirectResponse + { + Gate::authorize('update', $municipalImport); + $data = $request->validate([ + 'decision' => ['required', 'in:publish,reject'], 'reason' => ['required', 'string', 'max:2000'], + 'review_token' => ['required', 'string', 'size:64'], + 'geometry_reviewed' => ['sometimes', 'boolean'], + ]); + $service->decide($municipalImport, $request->user(), $data['decision'], $data['reason'], $data['review_token'], (bool) ($data['geometry_reviewed'] ?? false)); + + return to_route('app.municipal-imports.show', $municipalImport); + } + + public function enable(Request $request, DatasetSource $datasetSource): RedirectResponse + { + Gate::authorize('create', MunicipalImport::class); + $request->validate(['terms_confirmed' => ['accepted'], 'reason' => ['required', 'string', 'max:2000']]); + $datasetSource->publication_enabled = true; + $datasetSource->terms_review = ['user_id' => $request->user()->id, 'at' => now()->toIso8601String(), 'reason' => $request->string('reason')->toString()]; + $datasetSource->save(); + + return to_route('app.municipal-imports.index'); + } +} diff --git a/app/Http/Requests/App/StoreMunicipalImportRequest.php b/app/Http/Requests/App/StoreMunicipalImportRequest.php new file mode 100644 index 00000000..f28a0858 --- /dev/null +++ b/app/Http/Requests/App/StoreMunicipalImportRequest.php @@ -0,0 +1,20 @@ +user()->can('create', MunicipalImport::class); + } + + /** @return array> */ + public function rules(): array + { + return ['file' => ['required', 'file', 'max:32768']]; + } +} diff --git a/app/Models/DatasetSource.php b/app/Models/DatasetSource.php new file mode 100644 index 00000000..515e3952 --- /dev/null +++ b/app/Models/DatasetSource.php @@ -0,0 +1,31 @@ + */ + use HasFactory; + + protected $dateFormat = 'Y-m-d H:i:s.u'; + + protected $fillable = ['code', 'name', 'selection', 'target_type', 'source_url', 'attribution', 'terms_url', 'municipality_id', 'bounds', 'publication_enabled']; + + protected $casts = ['terms_review' => 'array', 'bounds' => 'array', 'publication_enabled' => 'boolean', 'last_published_retrieved_at' => 'immutable_datetime']; + + public function municipality(): BelongsTo + { + return $this->belongsTo(Municipality::class); + } + + /** @return array */ + public function configuration(): array + { + return [...$this->only($this->fillable), 'country_id' => $this->municipality->country_id, 'province_id' => $this->municipality->province_id]; + } +} diff --git a/app/Models/MunicipalImport.php b/app/Models/MunicipalImport.php new file mode 100644 index 00000000..9474eb84 --- /dev/null +++ b/app/Models/MunicipalImport.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + protected $dateFormat = 'Y-m-d H:i:s.u'; + + protected $fillable = ['dataset_source_id', 'delivery_id', 'fingerprint', 'retrieved_at', 'dataset_config', 'records', 'submitted_by']; + + protected $casts = ['dataset_config' => 'array', 'records' => 'array', 'before_values' => 'array', 'retrieved_at' => 'immutable_datetime', 'reviewed_at' => 'immutable_datetime']; + + protected $hidden = ['records', 'before_values', 'dataset_config']; + + public function datasetSource(): BelongsTo + { + return $this->belongsTo(DatasetSource::class); + } +} diff --git a/app/Models/ParkingMunicipal.php b/app/Models/ParkingMunicipal.php index df95ce79..fbd338b6 100644 --- a/app/Models/ParkingMunicipal.php +++ b/app/Models/ParkingMunicipal.php @@ -17,6 +17,8 @@ class ParkingMunicipal extends Model protected $table = 'parking_municipal_spaces'; + protected $hidden = ['source_record', 'geometry_derivation', 'last_imported_values']; + protected $primaryKey = 'id'; protected $keyType = 'string'; @@ -42,6 +44,10 @@ class ParkingMunicipal extends Model * @var array */ protected $casts = [ + 'source_record' => 'array', + 'geometry_derivation' => 'array', + 'last_imported_values' => 'array', + 'last_checked_at' => 'immutable_datetime', 'orientation' => ParkingOrientation::class, 'updated_at' => 'datetime', 'created_at' => 'datetime', diff --git a/app/Policies/MunicipalImportPolicy.php b/app/Policies/MunicipalImportPolicy.php new file mode 100644 index 00000000..37f81c97 --- /dev/null +++ b/app/Policies/MunicipalImportPolicy.php @@ -0,0 +1,30 @@ +hasRole(UserRole::ADMIN); + } + + public function view(User $user, MunicipalImport $municipalImport): bool + { + return $this->viewAny($user); + } + + public function create(User $user): bool + { + return $this->viewAny($user); + } + + public function update(User $user, MunicipalImport $municipalImport): bool + { + return $this->viewAny($user); + } +} diff --git a/app/Services/MunicipalImportService.php b/app/Services/MunicipalImportService.php new file mode 100644 index 00000000..2a504725 --- /dev/null +++ b/app/Services/MunicipalImportService.php @@ -0,0 +1,205 @@ +authorize('create', MunicipalImport::class); + $data = $this->snapshot->decode($json); + $source = DatasetSource::where('code', $data['dataset'])->first(); + if (! $source) { + throw ValidationException::withMessages(['dataset' => 'Deze dataset is niet geregistreerd.']); + } + $fingerprint = hash('sha256', $json); + $existing = MunicipalImport::where('dataset_source_id', $source->id)->where('delivery_id', $data['delivery_id'])->first(); + if ($existing) { + return $this->existingDelivery($existing, $fingerprint); + } + $configuration = $source->configuration(); + $records = $this->snapshot->validate($data, $source); + + return DB::transaction(function () use ($source, $configuration, $records, $data, $fingerprint, $actor) { + $source = DatasetSource::whereKey($source->id)->lockForUpdate()->firstOrFail(); + $existing = MunicipalImport::where('dataset_source_id', $source->id)->where('delivery_id', $data['delivery_id'])->first(); + if ($existing) { + return $this->existingDelivery($existing, $fingerprint); + } + if (MunicipalSnapshot::fingerprint($source->configuration()) !== MunicipalSnapshot::fingerprint($configuration)) { + throw ValidationException::withMessages(['dataset' => 'De datasetconfiguratie is gewijzigd. Lees het bestand opnieuw in.']); + } + + return MunicipalImport::create([ + 'dataset_source_id' => $source->id, 'delivery_id' => $data['delivery_id'], + 'fingerprint' => $fingerprint, 'retrieved_at' => $data['retrieved_at'], + 'dataset_config' => $configuration, 'records' => $records, 'submitted_by' => $actor->id, + ])->refresh(); + }); + } + + private function existingDelivery(MunicipalImport $import, string $fingerprint): MunicipalImport + { + if ($import->fingerprint !== $fingerprint) { + throw ValidationException::withMessages(['delivery_id' => 'Deze leverings-ID is al gebruikt voor andere inhoud.']); + } + + return $import; + } + + /** @return array */ + public function review(MunicipalImport $import, bool $lock = false): array + { + $source = $import->datasetSource; + $query = ParkingMunicipal::where('dataset_source_id', $source->id)->orderBy('id'); + if ($lock) { + $query->lockForUpdate(); + } + $existing = $query->get()->keyBy('external_id'); + $rows = []; + $counts = ['new' => 0, 'changed' => 0, 'unchanged' => 0, 'missing' => 0, 'conflict' => 0]; + foreach ($import->records as $record) { + $id = $record['source']['external_id']; + $space = $existing->get($id); + $fields = []; + $conflicts = []; + if ($space) { + foreach (array_unique([...array_keys($record['source']), ...array_keys($space->source_record ?? [])]) as $field) { + $value = $record['source'][$field] ?? null; + if (MunicipalSnapshot::fingerprint($value) !== MunicipalSnapshot::fingerprint($space->source_record[$field] ?? null)) { + $fields[] = $field; + } + } + foreach ($record['values'] as $field => $value) { + if ($space->last_imported_values === null || (MunicipalSnapshot::fingerprint($this->value($space, $field)) !== MunicipalSnapshot::fingerprint($space->last_imported_values[$field]) && MunicipalSnapshot::fingerprint($value) !== MunicipalSnapshot::fingerprint($space->last_imported_values[$field]) && MunicipalSnapshot::fingerprint($value) !== MunicipalSnapshot::fingerprint($this->value($space, $field)))) { + $conflicts[] = $field; + } + } + } + if ($space && MunicipalSnapshot::fingerprint($record['geometry_derivation'] ?? null) !== MunicipalSnapshot::fingerprint($space->geometry_derivation)) { + $fields[] = 'geometry_derivation'; + } + $status = ! $space ? 'new' : ($conflicts ? 'conflict' : ($fields ? 'changed' : 'unchanged')); + $counts[$status]++; + $rows[] = [ + 'external_id' => $id, 'status' => $status, 'fields' => $fields, 'conflicts' => $conflicts, + 'before' => $space?->source_record, 'after' => $record['source'], + 'geometry_derivation' => $record['geometry_derivation'] ?? null, + 'previous_geometry_derivation' => $space?->geometry_derivation, + 'point' => ['latitude' => $record['values']['latitude'], 'longitude' => $record['values']['longitude']], + 'current' => $space?->only(['id', 'number', 'street', 'orientation', 'latitude', 'longitude', 'visibility']), + ]; + $existing->forget($id); + } + foreach ($existing as $space) { + $counts['missing']++; + $rows[] = ['external_id' => $space->external_id, 'status' => 'missing', 'fields' => [], 'conflicts' => [], 'before' => $space->source_record, 'after' => null, 'current' => $space->only(['id', 'visibility'])]; + } + $rows = collect($rows)->sortByDesc(fn ($row) => ! empty($row['geometry_derivation']))->values()->all(); + $derivations = count(array_filter($rows, fn ($row) => ! empty($row['geometry_derivation']))); + $blockers = []; + if (! $source->publication_enabled) { + $blockers[] = 'Publicatie is nog niet ingeschakeld voor deze dataset. Bevestig eerst de bronvoorwaarden.'; + } + if (MunicipalSnapshot::fingerprint($source->configuration()) !== MunicipalSnapshot::fingerprint($import->dataset_config)) { + $blockers[] = 'De datasetconfiguratie is gewijzigd sinds ontvangst. Lever een nieuw bestand aan.'; + } + if ($source->last_published_retrieved_at && $import->retrieved_at->lessThanOrEqualTo($source->last_published_retrieved_at)) { + $blockers[] = 'Deze levering is niet nieuwer dan de laatst gepubliceerde levering.'; + } + if ($counts['conflict'] > 0) { + $blockers[] = 'Bronwijzigingen conflicteren met handmatig aangepaste velden. Publicatie is geblokkeerd.'; + } + + return ['rows' => $rows, 'counts' => $counts, 'derivations' => $derivations, 'blockers' => $blockers, 'token' => MunicipalSnapshot::fingerprint([$source->configuration(), $source->last_published_retrieved_at, $rows])]; + } + + public function decide(MunicipalImport $import, User $actor, string $decision, string $reason, string $reviewToken, bool $geometryReviewed = false): void + { + Gate::forUser($actor)->authorize('update', $import); + DB::transaction(function () use ($import, $actor, $decision, $reason, $reviewToken, $geometryReviewed) { + $source = DatasetSource::whereKey($import->dataset_source_id)->lockForUpdate()->firstOrFail(); + $import = MunicipalImport::whereKey($import->id)->lockForUpdate()->firstOrFail(); + $import->setRelation('datasetSource', $source); + if ($import->state !== 'pending') { + throw ValidationException::withMessages(['decision' => 'Deze levering is al beoordeeld.']); + } + if ($decision === 'publish') { + $review = $this->review($import, true); + if ($review['blockers'] || ! hash_equals($review['token'], $reviewToken)) { + throw ValidationException::withMessages(['decision' => $review['blockers'] ?: ['De gegevens zijn veranderd. Bekijk de verschillen opnieuw.']]); + } + if ($review['derivations'] > 0 && ! $geometryReviewed) { + throw ValidationException::withMessages(['geometry_reviewed' => 'Bevestig dat je de afgeleide geometrieën hebt beoordeeld.']); + } + $this->publish($import, $source); + $source->last_published_retrieved_at = $import->retrieved_at; + $source->save(); + } elseif ($decision !== 'reject') { + throw ValidationException::withMessages(['decision' => 'Ongeldige beslissing.']); + } + $import->forceFill(['state' => $decision === 'publish' ? 'published' : 'rejected', 'reviewed_by' => $actor->id, 'review_reason' => $reason, 'reviewed_at' => now()])->save(); + }); + } + + private function publish(MunicipalImport $import, DatasetSource $source): void + { + $municipality = $source->municipality; + $existing = ParkingMunicipal::where('dataset_source_id', $source->id)->get()->keyBy('external_id'); + $updates = []; + $before = []; + foreach ($import->records as $record) { + $externalId = $record['source']['external_id']; + $space = $existing->get($externalId); + if ($space && MunicipalSnapshot::fingerprint($space->source_record) === MunicipalSnapshot::fingerprint($record['source']) && MunicipalSnapshot::fingerprint($space->geometry_derivation) === MunicipalSnapshot::fingerprint($record['geometry_derivation'] ?? null)) { + continue; + } + $values = $record['values']; + if ($space) { + foreach ($values as $field => $value) { + if ($space->last_imported_values === null || MunicipalSnapshot::fingerprint($this->value($space, $field)) !== MunicipalSnapshot::fingerprint($space->last_imported_values[$field])) { + $values[$field] = $this->value($space, $field); + } + } + } + $id = $space?->id ?? (string) Str::uuid(); + $before[$id] = $space?->getAttributes(); + unset($before[$id]['location']); + $updates[] = [ + 'id' => $id, 'dataset_source_id' => $source->id, 'external_id' => $externalId, + 'country_id' => $municipality->country_id, 'province_id' => $municipality->province_id, 'municipality_id' => $municipality->id, + ...$values, 'visibility' => $space?->visibility ?? true, + 'source_record' => json_encode($record['source'], JSON_THROW_ON_ERROR), + 'geometry_derivation' => isset($record['geometry_derivation']) ? json_encode($record['geometry_derivation'], JSON_THROW_ON_ERROR) : null, + 'last_imported_values' => json_encode($record['values'], JSON_THROW_ON_ERROR), + 'last_checked_at' => now(), 'created_at' => $space?->created_at ?? now(), 'updated_at' => now(), + ]; + } + foreach (array_chunk($updates, 100) as $chunk) { + ParkingMunicipal::upsert($chunk, ['id'], ['street', 'number', 'orientation', 'latitude', 'longitude', 'source_record', 'geometry_derivation', 'last_imported_values', 'last_checked_at', 'updated_at']); + } + ParkingMunicipal::where('dataset_source_id', $source->id) + ->whereIn('external_id', array_column(array_column($import->records, 'source'), 'external_id')) + ->toBase()->update(['last_checked_at' => now()]); + $import->before_values = $before; + } + + private function value(ParkingMunicipal $space, string $field): mixed + { + $value = $space->getAttribute($field); + + return $value instanceof \BackedEnum ? $value->value : $value; + } +} diff --git a/app/Support/MunicipalSnapshot.php b/app/Support/MunicipalSnapshot.php new file mode 100644 index 00000000..d018a270 --- /dev/null +++ b/app/Support/MunicipalSnapshot.php @@ -0,0 +1,215 @@ + */ + public function decode(string $json): array + { + if (strlen($json) > self::MAX_BYTES) { + $this->fail('file', 'Het bestand is groter dan 32 MiB.'); + } + try { + $object = json_decode($json, false, 32, JSON_THROW_ON_ERROR); + if (! $object instanceof stdClass) { + $this->fail('file', 'Verwacht één JSON-object.'); + } + $this->rejectDuplicateKeys($json); + $data = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $this->fail('file', 'Ongeldige JSON of te diep geneste gegevens.'); + } + Validator::make($data, [ + 'format' => ['required', 'in:nipkaart-municipal-pilot-1'], + 'dataset' => ['required', 'string', 'max:255'], + 'delivery_id' => ['required', 'uuid'], + 'retrieved_at' => ['required', 'string', 'regex:/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/', 'date'], + 'selection' => ['required', 'string'], + 'records' => ['required', 'array', 'list', 'min:1', 'max:10000'], + ])->validate(); + if (($data['complete'] ?? null) !== true || ! is_int($data['source_count'] ?? null) || $data['source_count'] !== count($data['records'])) { + $this->fail('file', 'De levering moet volledig zijn en het bronaantal moet overeenkomen.'); + } + if (CarbonImmutable::parse($data['retrieved_at'])->isFuture()) { + $this->fail('retrieved_at', 'Het ophaaltijdstip ligt in de toekomst.'); + } + foreach ($object->records as $index => $record) { + if (! $record instanceof stdClass || ! ($record->geometry ?? null) instanceof stdClass || ! ($record->source_attributes ?? null) instanceof stdClass) { + $this->fail("records.$index", 'Een record, geometrie en bronattributen moeten objecten zijn.'); + } + } + + return $data; + } + + /** @param array $data + * @return list> + */ + public function validate(array $data, DatasetSource $source): array + { + if ($source->code !== 'nl-amsterdam-parkeervakken-e6a' || $source->target_type !== 'municipal' || $source->selection !== $data['selection']) { + $this->fail('dataset', 'Dataset of selectie is niet toegelaten voor deze pilot.'); + } + $seen = []; + foreach ($data['records'] as $index => $record) { + $prefix = "records.$index"; + $errors = Validator::make($record, [ + 'external_id' => ['required', 'string', 'max:255'], + 'number' => ['present', 'nullable', 'integer', 'min:0', 'max:2147483647'], + 'street' => ['present', 'nullable', 'string', 'max:255'], + 'access_category' => ['required', 'in:general'], + 'source_updated_at' => ['present'], + 'source_attributes' => ['required', 'array:regimes,orientation,version_date'], + 'source_attributes.orientation' => ['present', 'nullable', 'string', 'max:255'], + 'source_attributes.version_date' => ['present', 'nullable', 'date_format:Y-m-d'], + 'source_attributes.regimes' => ['required', 'array', 'list', 'min:1', 'max:100'], + 'geometry.type' => ['required', 'in:Polygon'], + 'geometry.coordinates' => ['required', 'array', 'list', 'min:1', 'max:100'], + ])->errors(); + if ($errors->isNotEmpty()) { + throw ValidationException::withMessages(collect($errors->messages())->mapWithKeys(fn ($messages, $field) => ["$prefix.$field" => $messages])->all()); + } + if ($record['external_id'] !== trim($record['external_id']) || isset($seen[$record['external_id']])) { + $this->fail("$prefix.external_id", 'Bron-ID is ongeldig of dubbel.'); + } + $seen[$record['external_id']] = true; + if ($record['source_updated_at'] !== null) { + $this->fail("$prefix.source_updated_at", 'De bronwijzigingsdatum is onbekend voor deze Amsterdam-pilot; verwacht null.'); + } + if ($record['number'] !== null && ! is_int($record['number'])) { + $this->fail("$prefix.number", 'Verwacht een geheel aantal of null.'); + } + foreach ($record['source_attributes']['regimes'] as $regime) { + if (! is_array($regime) || ($regime['eType'] ?? null) !== 'E6a' || ($regime['eTypeDescription'] ?? null) !== 'Gehandicaptenparkeerplaats algemeen' || ! in_array($regime['kenteken'] ?? null, [null, ''], true)) { + $this->fail("$prefix.source_attributes.regimes", 'Onbekende of persoonsgebonden parkeerregeling; selectie opnieuw beoordelen.'); + } + } + $positions = 0; + foreach ($record['geometry']['coordinates'] as $ring) { + if (! is_array($ring) || ! array_is_list($ring) || count($ring) < 4 || $ring[0] !== $ring[array_key_last($ring)]) { + $this->fail("$prefix.geometry", 'Ongeldige of niet gesloten polygoonring.'); + } + $positions += count($ring); + if ($positions > 10000) { + $this->fail("$prefix.geometry", 'Te veel posities in de polygoon.'); + } + foreach ($ring as $point) { + if (! is_array($point) || ! array_is_list($point) || count($point) !== 2 || ! is_numeric($point[0]) || ! is_numeric($point[1]) || is_string($point[0]) || is_string($point[1]) || $point[0] < -180 || $point[0] > 180 || $point[1] < -90 || $point[1] > 90) { + $this->fail("$prefix.geometry", 'Ongeldige WGS84-coördinaten.'); + } + } + } + } + [$west, $south, $east, $north] = $source->bounds; + $rows = DB::select(<<<'SQL' + WITH shapes AS ( + SELECT item->>'external_id' AS external_id, ST_SetSRID(ST_GeomFromGeoJSON((item->'geometry')::text),4326) AS shape + FROM jsonb_array_elements(?::jsonb) AS item + ), assessed AS ( + SELECT *, ST_IsValid(shape) AS original_valid, ST_IsValidReason(shape) AS reason FROM shapes + ), derived AS ( + SELECT *, CASE WHEN original_valid THEN shape ELSE ST_MakeValid(shape, 'method=linework') END AS usable FROM assessed + ), points AS ( + SELECT *, ST_PointOnSurface(usable) AS point FROM derived + ) SELECT external_id, original_valid, reason, + ST_IsValid(usable) AND NOT ST_IsEmpty(usable) AND GeometryType(usable) IN ('POLYGON', 'MULTIPOLYGON') AS usable, + ST_CoveredBy(ST_Envelope(shape), ST_MakeEnvelope(?, ?, ?, ?, 4326)) AS inside, + ST_X(point) AS longitude, ST_Y(point) AS latitude, + CASE WHEN NOT original_valid THEN ST_AsGeoJSON(usable, 15, 0) END AS geometry, + PostGIS_Lib_Version() || ' / GEOS ' || PostGIS_GEOS_Version() AS engine + FROM points + SQL, [json_encode($data['records'], JSON_THROW_ON_ERROR), $west, $south, $east, $north]); + $points = []; + $derivations = []; + $errors = []; + foreach ($rows as $row) { + if (! $row->usable || ! $row->inside) { + $errors["geometry.{$row->external_id}"] = "Bron-ID {$row->external_id}: ".(! $row->inside ? 'buiten het toegelaten gebied.' : "geen bruikbaar parkeervlak na afleiding: {$row->reason}"); + + continue; + } + $points[$row->external_id] = ['longitude' => round($row->longitude, 7), 'latitude' => round($row->latitude, 7)]; + $derivations[$row->external_id] = $row->original_valid ? null : [ + 'method' => 'st_makevalid_linework', 'reason' => $row->reason, 'engine' => $row->engine, + 'geometry' => json_decode($row->geometry, true, 32, JSON_THROW_ON_ERROR), + ]; + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + return array_map(fn ($record) => [ + 'source' => $record, + 'geometry_derivation' => $derivations[$record['external_id']], + 'values' => [ + 'street' => $record['street'], 'number' => $record['number'], + 'orientation' => match ($record['source_attributes']['orientation']) { + 'Haaks', 'Dwars' => 'perpendicular', 'Langs' => 'parallel', 'Schuin', 'Visgraat', 'Vissengraat' => 'angle', default => null, + }, + ...$points[$record['external_id']], + ], + ], $data['records']); + } + + /** Canonical comparison ignores object key ordering while preserving array order. */ + public static function fingerprint(mixed $value): string + { + $normalize = function (mixed $item) use (&$normalize): mixed { + if (! is_array($item)) { + return $item; + } + if (! array_is_list($item)) { + ksort($item); + } + + return array_map($normalize, $item); + }; + + return hash('sha256', json_encode($normalize($value), JSON_THROW_ON_ERROR)); + } + + /** JSON decoding is already complete; this pass rejects duplicate object keys. */ + private function rejectDuplicateKeys(string $json): void + { + preg_match_all('/"(?:[^"\\\\]|\\\\.)*"|[{}\[\]:,]/s', $json, $matches); + $stack = []; + foreach ($matches[0] as $token) { + if ($token === '{' || $token === '[') { + $stack[] = ['object' => $token === '{', 'key' => $token === '{', 'seen' => []]; + } elseif ($token === '}' || $token === ']') { + array_pop($stack); + } elseif ($stack !== []) { + $i = array_key_last($stack); + if ($stack[$i]['object']) { + if ($token === ',') { + $stack[$i]['key'] = true; + } elseif ($token === ':') { + $stack[$i]['key'] = false; + } elseif ($token[0] === '"' && $stack[$i]['key']) { + $key = json_decode($token, true, 32, JSON_THROW_ON_ERROR); + if (isset($stack[$i]['seen'][$key])) { + $this->fail('file', 'Dubbele JSON-sleutel: '.$key); + } + $stack[$i]['seen'][$key] = true; + } + } + } + } + } + + private function fail(string $field, string $message): never + { + throw ValidationException::withMessages([$field => $message]); + } +} diff --git a/database/factories/DatasetSourceFactory.php b/database/factories/DatasetSourceFactory.php new file mode 100644 index 00000000..a6cc450a --- /dev/null +++ b/database/factories/DatasetSourceFactory.php @@ -0,0 +1,26 @@ + */ +class DatasetSourceFactory extends Factory +{ + public function definition(): array + { + return [ + 'code' => 'nl-amsterdam-parkeervakken-e6a', 'name' => 'Amsterdam', + 'selection' => 'e6a-all', 'target_type' => 'municipal', + 'source_url' => 'https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken/', + 'attribution' => 'Gemeente Amsterdam — CC0', + 'terms_url' => 'https://data.overheid.nl/dataset/318a98b8-ef87-4335-9674-f5405f2bc4be', + 'municipality_id' => Municipality::factory()->state(['name' => 'Amsterdam'])->for(Province::factory()->state(['geocode' => 'NL-NH'])->for(Country::factory()->state(['code' => 'NL']))), + 'bounds' => [4.65, 52.2, 5.15, 52.5], 'publication_enabled' => true, + ]; + } +} diff --git a/database/factories/MunicipalImportFactory.php b/database/factories/MunicipalImportFactory.php new file mode 100644 index 00000000..54e43dae --- /dev/null +++ b/database/factories/MunicipalImportFactory.php @@ -0,0 +1,21 @@ + */ +class MunicipalImportFactory extends Factory +{ + public function definition(): array + { + return [ + 'dataset_source_id' => DatasetSource::factory(), 'delivery_id' => fake()->uuid(), + 'fingerprint' => hash('sha256', 'example'), 'retrieved_at' => now()->subMinute(), + 'dataset_config' => fn (array $attributes) => DatasetSource::findOrFail($attributes['dataset_source_id'])->configuration(), + 'records' => [], + ]; + } +} diff --git a/database/migrations/2026_09_14_065958_create_municipal_import_tables.php b/database/migrations/2026_09_14_065958_create_municipal_import_tables.php new file mode 100644 index 00000000..3639739c --- /dev/null +++ b/database/migrations/2026_09_14_065958_create_municipal_import_tables.php @@ -0,0 +1,67 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('selection'); + $table->string('target_type')->default('municipal'); + $table->text('source_url'); + $table->text('attribution'); + $table->text('terms_url'); + $table->foreignId('municipality_id')->constrained()->restrictOnDelete(); + $table->jsonb('bounds'); + $table->boolean('publication_enabled')->default(false); + $table->jsonb('terms_review')->nullable(); + $table->timestampTz('last_published_retrieved_at', 6)->nullable(); + $table->timestampsTz(); + }); + Schema::create('municipal_imports', function (Blueprint $table) { + $table->id(); + $table->foreignId('dataset_source_id')->constrained()->restrictOnDelete(); + $table->uuid('delivery_id'); + $table->string('fingerprint', 64); + $table->timestampTz('retrieved_at', 6); + $table->string('state')->default('pending'); + $table->jsonb('dataset_config'); + $table->jsonb('records'); + $table->jsonb('before_values')->nullable(); + $table->foreignId('submitted_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('review_reason')->nullable(); + $table->timestampTz('reviewed_at')->nullable(); + $table->timestampsTz(); + $table->unique(['dataset_source_id', 'delivery_id']); + }); + Schema::table('parking_municipal_spaces', function (Blueprint $table) { + $table->integer('number')->nullable()->change(); + $table->foreignId('dataset_source_id')->nullable()->constrained()->restrictOnDelete(); + $table->string('external_id')->nullable(); + $table->jsonb('source_record')->nullable(); + $table->jsonb('geometry_derivation')->nullable(); + $table->jsonb('last_imported_values')->nullable(); + $table->timestampTz('last_checked_at')->nullable(); + $table->unique(['dataset_source_id', 'external_id']); + }); + } + + public function down(): void + { + Schema::table('parking_municipal_spaces', function (Blueprint $table) { + $table->dropUnique(['dataset_source_id', 'external_id']); + $table->dropConstrainedForeignId('dataset_source_id'); + $table->dropColumn(['external_id', 'source_record', 'geometry_derivation', 'last_imported_values', 'last_checked_at']); + }); + Schema::dropIfExists('municipal_imports'); + Schema::dropIfExists('dataset_sources'); + // Nullable capacity remains: restoring NOT NULL would lose unknown values. + } +}; diff --git a/docs/development/data-foundation-delivery.md b/docs/development/data-foundation-delivery.md new file mode 100644 index 00000000..c5927918 --- /dev/null +++ b/docs/development/data-foundation-delivery.md @@ -0,0 +1,70 @@ +# Batchimports: uitvoering en beheer + +Status: uitvoering van het KISS-plan uit [core #1176](https://github.com/NIPKaart/core/issues/1176). De [pilotbeschrijving](data-import-pilot.md), [leveringsafspraak](data-import-contract.md) en [techstack](data-foundation-stack.md) geven de concrete eerste stap. Geen productie-import of infrastructuur is door deze documentatie geactiveerd. + +## Werkvolgorde + +| Stap | Issue | Klaar wanneer | +| --- | --- | --- | +| Voorbereiding | disabled-parking #778 | Afgerond met gemergede #779 (uv), #780 (SQL-verwijdering) en #781 (klein voorbeeld). Dit bewijst nog geen live aansluiting. | +| Bron en bestand | core #1214 | Eén bruikbare bron, hergebruikbewijs, betekenis, beperkingen, voorbeeldrij en voorlopige levering zijn beschreven. | +| Live export | disabled-parking #774 | Eén commando haalt die bron volledig via de package op en schrijft het afgesproken bestand; failures behouden de vorige geldige export. | +| Handmatige keten | core #1215 | Werkelijke eerste en herhaalde levering beoordeeld verwerkt, met veilige wijzigingen, ontbrekende records en correctiebehoud. | +| Opslagbesluit | core #1217 | Provider, regio, kosten, rechten, retentie en veilige voltooiing van een upload zijn gekozen en getest vóór inzet. R2 blijft kandidaat. | +| Automatisch leveren | disabled-parking #775 | Een geplande eindige uitvoering uploadt hetzelfde bestandsformaat naar de private bucket. | +| Automatisch ontvangen | core #1216 | Core ontdekt complete bestanden en gebruikt hetzelfde intakepad als bij de lokale proef. | +| Correcties uitbreiden | core #1218 | Bijdragen, beoordeling en intrekken van correcties zijn uitgewerkt. Basisbehoud van bestaande correcties hoort al bij #1215. | +| Actualiteit | core #1219 | Ontvangst, publicatie, brondatum en uitblijvende leveringen zijn afzonderlijk zichtbaar. | +| Ketenacceptatie | core #1220 | De automatische stagingketen, herstel en correctie/herimport werken samen. | +| Tweede bron | disabled-parking #776 | Een tweede Europese bron bewijst hergebruik na de eerste ketenacceptatie. | +| Offstreet | offstreet-parking #656/#657, core #1221 | Eerst catalogus, daarna afzonderlijke bezettingsbetekenis en passend ritme. | + +Issues behouden hun eigen acceptatiecriteria. Een merge van een voorbeeld, groene CI of bereikbare endpoint bewijst geen volledige dataset of beoordeelde publicatie. + +## Eerste werkende keten + +1. Leg in core één toegelaten dataset vast met bronhouder, licentie/attributie, vaste selectie, geografische mapping en publicatiebeleid. Brononderzoek gebeurt buiten het platform; geen CRM bouwen. +2. Laat de universele package de bron begrijpen en volledig ophalen. Generieke endpoint-, parser- en pagineringsfixes horen daar; NIPKaart-velden niet. +3. Laat disabled-parking de bronobjecten vertalen naar één begrensd JSON-bestand met identiteit, ophaaltijd en onderbouwde volledigheid. +4. Laat core het bestand valideren en verschillen tonen vóór publicatie. Eerste publicatie wordt beoordeeld; gewone bestandsintake publiceert niets vanzelf. +5. Bewijs herhaling, wijziging, oudere/incomplete/lege levering, ontbrekend record en geaccepteerde correctie. Identiteit en bestaande verwijzingen blijven behouden. + +#774 vervangt `municipal-records-draft`, onvoorwaardelijke null-metadata en achterhaalde voorbeeldcode. #1215 vervangt of verwijdert de schemas en validator uit #1222 zodra het echte intakepad ze opvolgt. Eén actief formaat, geen parallelle compatibiliteitslaag. Een lokale export en kleine offline tests blijven nuttig. + +## Automatisering na de handmatige proef + +`gemeentelijke API → universele package → producer → private bucket → core intake/review → PostgreSQL/PostGIS → publieke discovery` + +De bucket is de afgesproken automatische overdracht. De producent heeft geen coretoken of databaseverbinding en blijft verantwoordelijk voor ophaalplanning. Core verwerkt leveringen en beheert beoordeling en publicatie. + +Begin met één geplande eindige uitvoering en maximaal één actieve ophaling per dataset. Stel deadlines en beperkte retries in; houd rekening met bronlimieten. De implementatie van host/timer, uploadvoltooiing en herstel hoort bij #775/#1217. Geen verplicht manifest, version-ID, gedistribueerde teller of permanente runnerstaat zonder aantoonbare behoefte. + +Automatische discovery gebruikt dezelfde validatie en importservice als de lokale proef. Een half bestand mag niet als complete levering worden verwerkt. Herontdekking is veilig, een oudere levering overschrijft geen nieuwere publicatie en een conflict omzeilt geen eerder besluit. Automatisch ontvangen betekent niet automatisch alle wijzigingen publiceren. + +## Beheer en herstel + +| Gebeurtenis | Actie | +| --- | --- | +| Bronfout of onvolledige selectie | Geen nieuwe geldige export afleveren; bestaande publicatie blijft staan. | +| Uploadfout | Hetzelfde geproduceerde bestand met dezelfde delivery-ID opnieuw proberen; geen tweede fetch voorstellen als dezelfde levering. | +| Core-uitval | Complete bestanden later opnieuw ontdekken; eerder ontvangen en afgeronde leveringen herkennen. | +| Onjuiste of verdachte levering | Publicatie blokkeren en beoordelen; niet automatisch bestaande records verwijderen. | +| Bron botst met lokale correctie | Bronwaarde afzonderlijk bewaren; correctie behouden en conflict tonen. | +| Gecompromitteerde producent | Rechten intrekken, ophaling en intake voor die dataset pauzeren en betrokken leveringen beoordelen. | +| Foute publicatie | Een nieuw herstelbesluit met behoud van actuele communitycorrecties; geen oude database over nieuwe bijdragen terugzetten. | + +Core toont wanneer iets is ontvangen, gevalideerd en gepubliceerd. Een verwerkingsdatum is geen veldwaarneming. De producent meldt fetch-/uploadfouten; core kan zonder aanvullende status alleen zien dat een levering uitblijft. Een apart monitoringplatform of statusfeed is geen pilotvoorwaarde. + +Vóór staging: keuze voor host/bucket, scoped rechten, harde grenzen en budget. Vóór productie: retentie, maximaal te overbruggen uitval, herstelproef, attributie en beheerrechten. Private opslag beschermt de operatie; de voorwaarden van iedere bron blijven gelden. Publieke discovery blijft begrensd en vraagt geen account voor basisgebruik. + +De [fresh-start-afspraak](postgresql.md#fresh-start-decision) blijft gelden. Geen verplichte historische MySQL-transfer. Productiejobs uitschakelen of een nieuwe dienst activeren is een afzonderlijke deploymentactie. + +## Verificatie + +Gewone CI gebruikt kleine offline voorbeelden. Tests richten zich op mapping en verliesrisico's, integriteit en veilige verwerking. Parser/paginering wordt in de universele package getest; geen fixturecorpus van alle gemeenten in disabled-parking. + +Naast offline CI komt in #775 een periodieke begrensde live controle: endpoint, verwacht responsetype/velden, identiteit en volledigheid. Een HTTP 200 alleen is onvoldoende. Een gewijzigde API of semantisch onvolledige package-output mag geen nieuwe geldige levering opleveren. Houd deze brongezondheid apart van package-unit-tests; een upstream storing maakt niet iedere code-PR rood. Frequentie en meldingen worden bij automatisering gekozen. + +Een begrensde live bronproef wordt afzonderlijk vastgelegd met packageversie, selectie, aantallen, omvang, tijd en concrete beperkingen. Daarna bewijst #1215 een daadwerkelijke lokale intake. #1220 bewijst later de automatische keten, inclusief uitval/herstel. Geen checkboxes afvinken op basis van uitsluitend een prototype. + +Volg [quality-checks.md](quality-checks.md) tijdens implementatie. Deze documentatie op zichzelf wijzigt geen applicatiegedrag en vereist geen databaseproef. diff --git a/docs/development/data-foundation-stack.md b/docs/development/data-foundation-stack.md new file mode 100644 index 00000000..ef19bbb1 --- /dev/null +++ b/docs/development/data-foundation-stack.md @@ -0,0 +1,25 @@ +# Techstack voor de eerste importketen + +Status: eenvoudige uitvoeringskeuzes bij [#1214](https://github.com/NIPKaart/core/issues/1214). Zie de [leveringsafspraak](data-import-contract.md) en [werkpakketten](data-foundation-delivery.md). Er is nog geen live producer/core-keten of bucket ingericht. + +| Onderdeel | Keuze voor de eerste stap | +| --- | --- | +| Core | Bestaande Laravel 13-applicatie, PHP 8.4 baseline en PostgreSQL/PostGIS. | +| Beheer | Bestaande React/Inertia-interface voor vergelijken en beoordelen; geen afzonderlijke importapp. | +| Producer | `disabled-parking`, bestaande Python >=3.11-omgeving en universele bronpackage. Bronpackageversies worden bij de noodzakelijke fix gecontroleerd. | +| Dependencybeheer | uv en `uv.lock`; installatie met `uv sync --locked`, daarna `uv run ...`. Universele packages houden hun eigen tooling. | +| Mapping | Kleine Python-dataclass en expliciete mapping voor één bron. | +| Bestand | Eén begrensd UTF-8 JSON-document, geschreven via tijdelijk bestand en atomische vervanging. | +| Validatie | Expliciete typen en inhoudscontroles aan beide kanten. Opis uit #1222 is beschikbaar als een klein gedeeld schema nuttig blijkt; geen schemarelease-infrastructuur vereist. | +| Tests | Bestaande Python unittest/pre-commit-checks en core Pest. Kleine mappingvoorbeelden; geen gemeentelijke netwerken in gewone CI. | +| Eerste overdracht | Lokaal bestand naar dezelfde core-intakeservice die later bucketbestanden verwerkt. | +| Automatische overdracht | Private bucket; R2 is kandidaat. Provider, regio, kosten, volledigheidsmechanisme en scoped rechten in #1217. | +| Uitvoering later | Eén geplande eindige producentuitvoering en core scheduler/queue. Hosting en ophaalritme pas kiezen bij #775/#1217. | + +De uv-migratie en SQL-runtimeverwijdering zijn gemerged in disabled-parking #779 en #780. #781 bevat één klein Hamburg-voorbeeld; dat is nog geen live aansluiting. De tijdelijke export wordt in #774 vervangen door de afgesproken pilotroute. Core bevat geen Python-code of bronclients. + +Gebruik bestaande standaardbibliotheken en dependencies waar die voldoen. Geen nieuwe broker, workerframework, lokale SQLite-planningsdatabase, verplichte sequences, JSONL of apart manifest voor de eerste bron. Voeg een component pas toe wanneer de werkende keten die aantoonbaar nodig heeft. + +De producent krijgt uitsluitend de noodzakelijke rechten op zijn eigen private bucketlocatie. Core krijgt aparte leesrechten en beslist over publicatie. Geen databasecredentials of coretoken in de producer. Het overdrachtsmechanisme moet complete, unieke leveringen garanderen; daarvoor is niet vooraf één bepaalde S3-versioningimplementatie voorgeschreven. + +Eerst handmatig de hele keten bewijzen, daarna dezelfde bestanden automatisch overdragen. Bij groei meten we looptijd, bronlimieten, bestandsomvang en beheerwerk voordat we extra processen of gedeelde frameworks toevoegen. Offstreet volgt met eigen inhoudelijke afspraken; algemene garagebezetting bewijst geen beschikbare gehandicaptenparkeerplaats. diff --git a/docs/development/data-import-contract.md b/docs/development/data-import-contract.md new file mode 100644 index 00000000..7ae405bf --- /dev/null +++ b/docs/development/data-import-contract.md @@ -0,0 +1,93 @@ +# Voorlopige gegevenslevering + +Status: werkafspraak voor [#1214](https://github.com/NIPKaart/core/issues/1214). Eén JSON-bestand voor de eerste handmatige import; het formaat wordt pas vastgezet nadat de producer en core samen zijn beproefd. De schema's, validator, Opis-dependency en fixturecorpus uit [PR #1222](https://github.com/NIPKaart/core/pull/1222) zijn vervangen door één daadwerkelijke intake in #1215. Tien ongeldige Amsterdamse polygonen krijgen in core een expliciet te beoordelen geometrie-afleiding. Het formaat is nog geen geaccepteerde productieaansluiting. + +## Van bron naar gebruiker + +`gemeentelijke API → universele Python-package → disabled-parking → JSON-bestand → core: valideren, vergelijken en beoordelen → gemeentelijke parkeergegevens → publieke discovery` + +De eerste overdracht gebeurt lokaal. Daarna uploadt de producent hetzelfde formaat naar een private bucket en ontdekt core de complete bestanden. De bucket hoort bij de automatische architectuur. R2 is een kandidaat; provider en overdrachtsmechanisme worden in #1217 gekozen. Er komt geen directe producerverbinding met de core-API of database. + +| Onderdeel | Verantwoordelijkheid | +| --- | --- | +| Universele package | Bronprotocol, volledige ophaling van een selectie, bron-ID's, oorspronkelijke waarden en volledigheidsinformatie. Zelfstandig bruikbaar zonder NIPKaart. | +| disabled-parking | Datasetselectie, vertaling, leveringsmetadata en bestand schrijven. Python en bronspecifieke tests staan hier of upstream. | +| core | Toegelaten dataset, bestandsvalidatie, verschillen, beoordeling, publicatie en behoud van correcties. Geen Python-omgeving. | +| offstreet-parking | Later een eigen producent voor voorzieningen; pas bij werkelijk gedeelde behoeften uitvoeringscode delen. | + +## Eén bestand + +UTF-8 JSON met één object en een `records`-array. Geen apart manifest, JSONL, schemarelease of opslagprovider-ID voor de lokale pilot. De concrete bronrij en mapping staan in de [pilotbeschrijving](data-import-pilot.md). + +| Veld | Betekenis | +| --- | --- | +| `format` | `nipkaart-municipal-pilot-1`; één voorlopige revisie voor producer en consumer. Vervangt `municipal-records-draft`, geen compatibiliteitslaag. | +| `dataset` | Vaste, door core toegelaten datasetcode. Bronhouder, licentie en geografische mapping horen bij deze aansluiting. | +| `delivery_id` | UUID, één keer gemaakt per geslaagde ophaling. Een retry van hetzelfde bestand behoudt ID en bytes. | +| `retrieved_at` | UTC-tijdstip waarop de ophaling begon, RFC 3339 met `Z`; sorteert leveringen. Geen waarnemingsdatum. Eén actieve ophaling per dataset. | +| `selection` | Vaste code voor de afgesproken collectie/filter, bijvoorbeeld `all`. Een scopewijziging vereist eerst een nieuwe beoordeelde aansluiting of selectie. | +| `complete` | Moet `true` zijn voor intake. Producent verklaart dit alleen op basis van bronbewijs. | +| `source_count` | Aantal dat de bron voor deze selectie meldt; gelijk aan aantal ontvangen unieke records. Een totaal alleen bewijst geen volledigheid wanneer nog een volgende pagina bestaat. | +| `records` | Alle records van de selectie, zonder stilzwijgend afgekeurde of overgeslagen bronrijen. | + +De producent controleert bronpagina's, aantallen en unieke ID's voordat het bestand wordt geschreven. Opvangen van parsefouten en doorgaan met de overige records is geen complete levering. Als een bron geen totaal aanbiedt, wordt eerst een andere aantoonbare volledigheidscontrole afgesproken; verzin geen `source_count` uit alleen de ontvangen lijst. + +Core accepteert alleen het bekende formaat en de toegelaten dataset/selectie; valideert typen en inhoud en weigert dubbele JSON-sleutels en bron-ID's. Geen remote schemaresolutie of door het bestand aangeleverde download-URL uitvoeren. Begin met de bestaande grenzen van 10.000 records en 32 MiB; de Amsterdamse bronproef van ongeveer 1,33 MB valt daar ruim binnen. Valideer vóór databasepublicatie. + +## Een record + +| Veld | Regel | +| --- | --- | +| `external_id` | Niet-lege oorspronkelijke ID als string. Identiteit is `(dataset, external_id)`; behoud volledige ID en voorloopnullen. Geen coördinatenhash of interne core-ID. | +| `geometry` | Oorspronkelijke GeoJSON `Polygon` in WGS84 voor Amsterdam, met `[longitude, latitude]`. Behoud ringen; begrens omvang en valideer bereik en geometrie. Geen verzonnen bronpunt. | +| `number` | Niet-negatief geheel aantal of `null`; voor Amsterdam een bronschatting, geen geverifieerde telling. Nul en onbekend blijven verschillend; maak van een bronaggregaat geen verzonnen losse bays. | +| `street` | Bronadres of `null`; een nabijheidsadres is geen exact parkeeradres. | +| `access_category` | Voor deze pilot uitsluitend `general`; een andere waarde blokkeert de levering. `general` betekent niet persoonsgebonden gehandicaptenparkeren, geen beschikbaarheid of parkeren zonder vergunning. | +| `source_attributes` | Voor Amsterdam: `regimes`, `orientation` en `version_date`. Alle regimes met hun tijden/dagen/datums/opmerkingen behouden; geen generiek regelsysteem of uitspraak “nu beschikbaar”. | +| `source_updated_at` | Voor deze Amsterdam-pilot verplicht aanwezig en uitsluitend `null`: de betekenis als wijziging van het bronrecord is niet aangetoond. Ook een lege string of array wordt geweigerd. Ondersteuning voor een echte bronwijzigingsdatum vereist eerst een beoordeelde bronmapping en bijbehorende contract- en validatorwijziging; portaalverwerking is geen veldcontrole. | + +Core bewaart de oorspronkelijke brongeometrie bij de bronclaim en gebruikt PostGIS `ST_PointOnSurface` voor de marker op de publieke kaart. De publieke kaart toont alleen die marker, geen parkeervlak. Een ongeldige polygoon krijgt waar mogelijk een afzonderlijke afleiding met `ST_MakeValid(geometry, 'method=linework')`; alleen een geldige, niet-lege Polygon of MultiPolygon is bruikbaar. Geometrieën die uiteenvallen in lijnen/punten of gemengde collecties worden geweigerd, zonder delen weg te filteren. Het kaartpunt wordt uit het bruikbare vlak berekend. Dit punt ligt binnen het bruikbare parkeervlak en is geen ingang of individueel vak; het hoeft niet het geometrische middelpunt te zijn. Core schrijft de bestaande latitude/longitude-kolommen; PostgreSQL blijft de bestaande `location` afleiden. Zo zijn geen extra Python-geometriepackage of twee concurrerende afleidingen nodig. Zie [PostGIS PointOnSurface](https://postgis.net/docs/ST_PointOnSurface.html), [MakeValid](https://postgis.net/docs/ST_MakeValid.html) en de [bestaande opslagafspraak](postgresql.md#spatial-representation). + +Land en administratieve relaties worden door core uit de toegelaten datasetconfiguratie gekoppeld. Geen Nederlandse verplichte codes voor Europese bronnen en geen interne foreign keys in het bestand. Deze pilot ondersteunt alleen de aangetroffen Polygon-geometrie. Puntbronnen of andere geometrieën krijgen pas ondersteuning wanneer ze worden aangesloten. Nuttige broninformatie wordt daarbij nooit stilzwijgend weggegooid. + +## Herhaling en wijzigingen + +| Geval | Gedrag | +| --- | --- | +| Eerste complete levering | Valideren, verschillen en kaartsteekproef tonen; publicatie na beoordeling. | +| Zelfde dataset en delivery-ID, dezelfde bytes | Bestaande importstatus teruggeven; geen tweede verwerking of publicatie. Core bewaart de SHA-256 van de ontvangen bytes. | +| Zelfde delivery-ID, andere bytes | Conflict afwijzen; nooit een bestaande levering vervangen. | +| Nieuwe levering, ongewijzigde records | Geen dubbele plekken of inhoudsrevisies; wel nieuwe ontvangst vastleggen. | +| Oudere `retrieved_at` dan de laatst geaccepteerde levering | Geen actuele gegevens overschrijven. Gelijke tijd met verschillende delivery-ID's is een conflict, geen willekeurige winnaar. | +| Een veld wijzigt bij dezelfde bron-ID | Nieuwe bronwaarde tonen voor beoordeling; identiteit, favorieten en detailverwijzingen behouden. | +| Een record ontbreekt in een complete selectie | Markeren als mogelijk verdwenen en beoordelen; geen automatische verwijdering. Terugkeer gebruikt dezelfde identiteit. | +| Lege/incomplete/ongeldige levering of mislukte fetch | Geen publicatie; bestaande gegevens en laatste geldige export blijven behouden. | +| Bronwaarde botst met geaccepteerde correctie | Bronwaarde in de ontvangen levering bewaren; correctie behouden en publicatie van de levering blokkeren bij een botsing. Ook in de eerste importimplementatie. | +| Onbekende toegang of gewijzigde scope | Geen automatische algemene publicatie of vergelijking van ontbrekende records; eerst beoordelen. | + +Core controleert volgorde en actuele correcties opnieuw bij publicatie, ook als twee beoordeelde imports tegelijk klaarstaan. `retrieved_at` is een eenvoudige volgorderegel voor één producent met correcte UTC-klok; het bewijst geen transactiesnapshot bij de bron. Toekomstige of onlogische tijdstippen vragen beoordeling. Geen gedistribueerde teller bouwen voor de pilot. + +## Concrete overdracht + +1. **#1214:** bronkeuze, toegestane voorbeeldrij, betekenis en deze voorlopige afspraak. +2. **disabled-parking #774:** eventuele generieke bronpackagefix eerst, daarna één live commando dat dit bestand atomair schrijft. Vervang draftformaat, null-volledigheidsmetadata en achterhaalde voorbeeldroute; behoud slechts nuttige kleine tests. +3. **core #1215:** één intakepad met beoordeling en veilige eerste, gewijzigde, herhaalde, oudere, ontbrekende en conflicterende levering. Vervang/verwijder de oude #1222-schema's en validatie in dezelfde implementatie. +4. **#1217, disabled-parking #775 en core #1216:** provider kiezen, dezelfde levering automatisch uploaden en ontdekken. De gekozen opslaggrens moet voorkomen dat core een gedeeltelijk bestand verwerkt. + +Gewone CI werkt offline met kleine voorbeelden van packageobjecten. Eén afzonderlijke begrensde live proef bewijst bronophaling; een daadwerkelijke beoordeelde core-import bewijst de volgende stap. Geen van beide wordt door alleen een fixturetest vervangen. + +## Lokale uitvoering en herstel + +1. Voer de migraties uit op de bedoelde ontwikkelomgeving en zorg dat de bestaande geografische relaties voor Amsterdam aanwezig zijn. Er worden geen legacygegevens automatisch gekoppeld of vervangen. +2. Registreer de aansluiting met `php artisan nipkaart:register-amsterdam `. Het commando controleert Amsterdam, land `NL` en provincie `NL-NH`; publicatie staat standaard uit. De ingestelde bbox `[4.65, 52.2, 5.15, 52.5]` is een ruime operationele begrenzing, geen officiële gemeentegrens. +3. Open als beheerder **Gemeentelijke imports**. Controleer de bronvoorwaarden en leg de onderbouwing vast vóór de eerste te publiceren levering. Een configuratiewijziging maakt oudere beoordelingen ongeldig; haal daarna een nieuwe levering op. +4. Bied het ongewijzigde producerbestand aan. Configureer PHP `upload_max_filesize` op minstens `32M` en `post_max_size` en de webserver-bodylimiet hoger dan 32 MiB voor multipart-overhead. Kleinere serverlimieten gelden vóór de applicatiecontrole. +5. Controleer aantallen, oorspronkelijke bronvelden, alle regelingen en een kaartsteekproef. De lijst toont maximaal 50 records per pagina; het parkeervlak en afgeleide punt zijn per record te openen. Goedkeuren en afwijzen vereisen een reden. Records met een afleiding staan bovenaan. Alleen het beheerscherm toont origineel en afleiding samen voor beoordeling, met onderscheid tussen lijnen en kleuren. Bij publicatie van een levering met afleidingen moet de beheerder expliciet bevestigen dat alle afleidingen zijn beoordeeld; afwijzen vereist die bevestiging niet. Ook de service dwingt dit af. + +`MunicipalImportService::intake()` is het gedeelde toegangspunt voor de upload en de latere bucketconsumer. Autorisatie geldt ook in de service. Eén datasetrij wordt vergrendeld tijdens publicatie; actuele bronrijen en importstatus worden opnieuw gecontroleerd. Een verouderde reviewtoken vereist opnieuw beoordelen. De levering, mutaties en laatste gepubliceerde ophaaltijd worden samen gecommit of teruggedraaid. Opnieuw aanbieden van dezelfde bytes geeft de bestaande status terug, ook na een configuratiewijziging. + +Bronclaims staan ongewijzigd in `source_record`. `geometry_derivation` bewaart bij een reparatie afzonderlijk de voorgestelde vorm, foutreden, methode en PostGIS/GEOS-versie; de ontvangen levering bewaart dezelfde afleiding. De normale review bewaart beoordelaar, tijdstip en reden. Er worden geen losse parkeerplaatsen uit MultiPolygon-delen gemaakt. Een nieuwe levering met dezelfde afleiding verandert geen inhoud; een later door de bron hersteld vlak verwijdert de actuele afleiding en behoudt de vorige in de importaudit. Bron en afleiding vallen onder de reviewtoken, zodat een gewijzigde afleiding opnieuw beoordeeld moet worden. `last_imported_values` bewaart de laatst afgeleide waarden. De huidige velden zijn de effectieve waarden: een afwijking ten opzichte van de vorige import geldt conservatief als handmatige correctie. Niet-conflicterende correcties en zichtbaarheid blijven behouden; een gelijktijdige afwijkende bronwijziging blokkeert publicatie. `last_checked_at` registreert de geslaagde controle, `source_updated_at` blijft onbekend. Bestaande niet-aangesloten gemeentelijke records blijven ongemoeid; aansluiting of reconciliatie daarvan is afzonderlijk werk. + +`municipal_imports.before_values` bewaart voor gewijzigde records de vorige databasewaarden en voor nieuw aangemaakte records `null`. De ontvangen levering en beoordeling blijven bewaard. Dit ondersteunt een onderbouwde herstelbeslissing; er is geen automatische terugzetknop die latere handmatige correcties kan overschrijven. Bij een databasefout blijft de levering te beoordelen en kan dezelfde publicatie opnieuw worden geprobeerd. De reviewpagina vergelijkt altijd met de huidige gegevens, ook na publicatie; historische verschillen zijn niet hetzelfde als deze actuele vergelijking. + +Een migratierollback verwijdert de importaudit en bronkoppeling. Maak eerst een databaseback-up en beoordeel herstel op gegevensniveau; een code-rollback is geen gegevensherstel. Onbekende capaciteit blijft nullable, ook na rollback. Productieactivering, legacyoverdracht, bucketcredentials en planning vallen buiten deze stap. diff --git a/docs/development/data-import-pilot.md b/docs/development/data-import-pilot.md new file mode 100644 index 00000000..8e7a9754 --- /dev/null +++ b/docs/development/data-import-pilot.md @@ -0,0 +1,129 @@ +# Pilot: algemene gehandicaptenparkeerplaatsen Amsterdam + +Status 2026-09-14: `odp-amsterdam` 7.0.0 is uitgebracht en [disabled-parking #783](https://github.com/NIPKaart/disabled-parking/pull/783) is gemerged. De live producer levert het afgesproken bestand. De eerste core-intake vond tien zelfdoorsnijdende polygonen. De gekozen vervolgafspraak is een afzonderlijke beoordeelde geometrie-afleiding in core, met behoud van de oorspronkelijke bron. Publieke ingebruikname is niet uitgevoerd. + +## Waarom deze bron + +De selectie `eType=E6a` benoemt algemene gehandicaptenparkeerplaatsen expliciet. Dat voorkomt de onbewezen algemene/persoonsgebonden interpretatie bij Eindhoven. Amsterdam levert oorspronkelijke string-ID's, parkeervlakken, aantallen, regimes en versie-informatie. De read-only proef ontving 1.420 unieke records; een afzonderlijke count-query meldde hetzelfde totaal. De [officiële datasetdocumentatie](https://api.data.amsterdam.nl/v1/docs/datasets/parkeervakken.html) beschrijft de velden. `aantal` is een geschatte capaciteit en `versiedatum` de geldigheidsdatum van de dataset; presenteer die niet als telling of veldcontrole. + +De leverancier is Gemeente Amsterdam. De dataset wordt in de [overheidscatalogus](https://data.overheid.nl/dataset/318a98b8-ef87-4335-9674-f5405f2bc4be) als CC0 aangeboden, bevestigd in de [CKAN-metadata](https://data.overheid.nl/data/api/3/action/package_show?id=318a98b8-ef87-4335-9674-f5405f2bc4be). De catalogus linkt oudere ontsluitingen van dezelfde dataset; het licentieveld in de huidige REST-documentatie is leeg. Dit is het traceerbare hergebruikbewijs voor de technische pilot, geen afzonderlijke nieuwe licentieverklaring van de REST-API. Hercontroleer deze koppeling en eventuele voorwaarden vóór publieke ingebruikname. De licentie van de Python-package is daarvan onafhankelijk. Bewaar bronvermelding bij de aansluiting, ook wanneer geen attributie verplicht is. + +| Onderdeel | Afbakening | +| --- | --- | +| Datasetcode | `nl-amsterdam-parkeervakken-e6a` | +| Selectiecode | `e6a-all` = alle records uit `parkeervakken/parkeervakken` met exact `eType=E6a`; geen bbox of aanvullende stille filtering. | +| Package | `odp-amsterdam==7.0.0` wordt door de producer gebruikt; de eerste onderzoeksproef gebruikte 6.0.0. | +| Identiteit | `properties.id`, als volledige string binnen de dataset. De GeoJSON-wrapper `parkeervakken.` wordt niet als tweede identiteit gebruikt. | +| Geografie | Nederland (`NL`), Noord-Holland (`NL-NH`), gemeente Amsterdam (`nl:cbs:municipality`, `0363`). Core koppelt deze codes aan relaties. | +| Betekenis | Algemene gehandicaptenparkeerplaats, mogelijk met tijdsbeperkingen. Geen actuele beschikbaarheid, geen garantie op toegankelijkheid voor ieder voertuig. | +| Granulariteit | Eén bronrecord beschrijft een parkeervlak met aantal; niet omzetten naar verzonnen individuele communityplekken. | + +## Gemeten bewijs en beperkingen + +| Meting | Uitkomst | +| --- | --- | +| Volledige GeoJSON-response | 1.420 features / 1.420 unieke `properties.id` | +| Onafhankelijke count-query | `X-Total-Count=1420`, `page.totalElements=1420` | +| Omvang | 1.332.469 bytes | +| SHA-256 volledige onderzoeksresponse | `b330ae455f3efbeb8373b419da6fad4f24da351cd0cf90547cb928811e0e6efa` | +| Geometrie | Alle records Polygon in WGS84 | +| Packageparser | Alle 1.420 bronrecords zijn met de geïnstalleerde parser gelezen | +| Regimes | 1.579 regimes; 159 records hebben meerdere regimes, 172 hebben tijdsvakken en 13 een opmerking | +| Toegang | Alle aangetroffen regimes beschrijven algemene gehandicaptenparkeerplaatsen; kentekenvelden zijn leeg | +| Versiedatum | Alle records `2026-09-11`; datasetgeldigheid, geen afzonderlijke recordwijziging of veldcontrole | +| Tijdstip | HTTP Date volledige response `2026-09-13T21:55:18Z`; count-response `2026-09-13T21:55:56Z` | + +Requests: [volledige selectie](https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken?_pageSize=2000&eType=E6a&_format=geojson) met header `Accept-Crs: EPSG:4326`, en [count-query](https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken?_pageSize=1&eType=E6a&_count=true). De GeoJSON-response heeft geen volgende pagina (`_links: []`). + +Dit bewijst volledige ontvangst ten opzichte van het toen gerapporteerde totaal, geen volledige werkelijkheid op straat of gegarandeerde transactiesnapshot. Het bronbestand blijft tijdelijk onderzoeksmateriaal; we voegen geen gemeentelijke fixturecorpus toe aan core. + +De destijds onderzochte package 6.0.0 deed één request met een limiet, geeft geen totalen/paginering door en gebruikt slechts delen van het eerste regime. Daardoor verdwijnen tijdsbeperkingen en de versiedatum; `int(aantal)` kan bovendien ongeldige fractionele waarden afronden. Succesvol parsen betekent dus niet dat de levering inhoudelijk volledig is. + +ID's zijn nu uniek en als bron-ID beschikbaar, maar toekomstige hernummering is niet uitgesloten. Grote identiteitswisselingen en verdwenen records vragen beoordeling. Niet terugvallen op coördinatenmatching. De API-documentatie kondigt verplichte API-keys aan; de proef werkte zonder key. Ondersteuning voor eventuele bronauthenticatie hoort in de universele package/producer, nooit in het afleverbestand. De bron biedt geen bewezen mutatieversie over meerdere pagina's: controleer aantallen vóór/na en geef bij verschillen geen complete levering af; gelijke aantallen bewijzen geen snapshotisolatie. + +## Concrete bronrij en vertaling + +Onderstaand voorbeeld is de echte bronrij `114323484886`, met alleen relevante velden. Het lege `kenteken` is weggelaten. Beide regimes blijven behouden; hun onderlinge betekenis wordt niet door de adapter gegokt. + +```json +{ + "id": "114323484886", + "straatnaam": "Pieter Calandlaan", + "eType": "E6a", + "type": "Haaks", + "aantal": 1.0, + "versiedatum": "2026-09-11", + "geometry": {"type": "Polygon", "coordinates": [[[4.790157860968076, 52.35038717769686], [4.790188598734742, 52.3503934444639], [4.79020922008023, 52.3503552603837], [4.790178482337059, 52.35034899362201], [4.790157860968076, 52.35038717769686]]]}, + "regimes": [ + {"soort": "MULDER", "eType": "E6a", "eTypeDescription": "Gehandicaptenparkeerplaats algemeen", "aantal": 1.0, "bord": "", "beginTijd": null, "eindTijd": null, "beginDatum": null, "eindDatum": null, "dagen": [], "opmerking": null, "typeUitzondering": "Venstertijden"}, + {"soort": "MULDER", "eType": "E6a", "eTypeDescription": "Gehandicaptenparkeerplaats algemeen", "aantal": 1.0, "bord": "", "beginTijd": "09:00:00", "eindTijd": "16:00:00", "beginDatum": null, "eindDatum": null, "dagen": ["ma", "di", "wo", "do"], "opmerking": null, "typeUitzondering": "Venstertijden"} + ] +} +``` + +De NIPKaart-representatie gebruikt onderstaande mapping. `geometry` en de twee `regimes` worden uit het voorbeeld ongewijzigd overgenomen; ze worden hier niet nogmaals gekopieerd. + +| Bron | Levering | Uitleg | +| --- | --- | --- | +| `properties.id` | `external_id="114323484886"` | Bronidentiteit; geen numerieke conversie of verkorting. | +| `geometry` | `geometry` | Volledig Polygon behouden. Core berekent pas bij intake een kaartpunt met PostGIS `ST_PointOnSurface`; geen Python-geometrieafhankelijkheid. | +| `aantal=1.0` | `number=1` | Geschatte capaciteit volgens de bron. Alleen na controle dat het getal eindig, geheel en niet-negatief is. `null` blijft onbekend, nul blijft nul. | +| `straatnaam` | `street="Pieter Calandlaan"` | Bronstraat, geen geocoding of afgeleid huisnummer. | +| `eType` en alle regimebeschrijvingen | `access_category="general"` | Alleen bij consistente algemene betekenis; onbekend of persoonsgebonden wordt niet algemeen verklaard. | +| Alle `regimes` | `source_attributes.regimes` | Tijd, dagen, datums, bord, uitzondering en opmerking blijven zichtbaar voor review. Geen berekende “nu beschikbaar”-status. | +| `type` | `source_attributes.orientation="Haaks"` | Oorspronkelijke plaatsingsaanduiding. | +| `versiedatum` | `source_attributes.version_date="2026-09-11"` | Datasetgeldigheid behouden; `source_updated_at=null` omdat dit geen afzonderlijke recordwijziging is. | +| `kenteken=null` | Niet afleveren | Verwacht leeg voor deze selectie. Een ingevuld kenteken of onverwachte persoonsgebonden betekenis blokkeert de levering; niet wegfilteren en alsnog compleet verklaren. | +| Wrapper-ID, buurtcode en dubbele broncategorie `soort` | Geen afzonderlijk generiek veld | Identiteit, geografie en categorie zijn al expliciet vastgelegd; regimegebonden `soort` blijft behouden. | + +Regimes zijn broninformatie die core moet tonen voordat deze records worden gepubliceerd. Het samengaan van een basisregime en tijdvenster wordt niet vertaald naar een belofte van onbeperkte toegang. Onbegrepen beperkingen blijven in review; een beheerder moet de betekenis kunnen onderbouwen of publicatie achterwege laten. + +De volledige levering volgt [het ene JSON-bestand](data-import-contract.md): `format=nipkaart-municipal-pilot-1`, bovengenoemde dataset/selectie, een werkelijke delivery-UUID en ophaaltijd, `complete=true`, een gecontroleerd `source_count` en alle records. Het voorbeeld hierboven is één record en mag nooit als volledige Amsterdamse levering worden aangeleverd. De toekomstige export mag 1.420 niet hardcoderen. + +## Opgelost in de universele package + +Afgerond met packageversie 7.0.0, oorspronkelijk package-issue: [python-odp-amsterdam #1291](https://github.com/klaasnicolaas/python-odp-amsterdam/issues/1291). Deze afhankelijkheid van disabled-parking #774 is inmiddels geleverd. De oorspronkelijke opdracht was: + +1. Stel de bron-ID, volledige geometrie, alle regimes en versiedatum beschikbaar. Bewaar oorspronkelijke aantallen zonder verliesgevende conversie; test onbekend, nul, fractioneel en meerdere regimes. +2. Bied een publieke volledige ophaling met totalen/pagina-informatie aan. Controleer de laatste pagina en unieke ID's; één `limit=2000` is geen blijvend volledigheidsbewijs. Behoud de bestaande beperkte ophaalmethode voor andere packagegebruikers indien die onderdeel is van de publieke API. +3. Test generieke paginering en gewijzigde/ontbrekende bronvelden upstream. Maak geen NIPKaart-bestandsformaat of publicatiebeleid onderdeel van de package. +4. Leg de geteste packageversie en één afzonderlijke begrensde live proef vast. Pas daarna de adapter op de nieuwe publieke package-interface aansluiten. + +[disabled-parking #783](https://github.com/NIPKaart/disabled-parking/pull/783) levert inmiddels het live commando en heeft de tijdelijke Hamburg-route vervangen. #1215 implementeert de ontvangende kant met geometrievalidatie, beoordeling en behoud van identiteit/correcties. De eerste werkende keten blijft handmatig; de private bucket volgt bij automatisering. + +## Andere onderzochte kandidaten + +- **Eindhoven:** de [metadata](https://data.eindhoven.nl/api/explore/v2.1/catalog/datasets/parkeerplaatsen) is opnieuw gecontroleerd; de eerdere proef van 2026-09-08 vond 180 records, maar de metadata noemt dekking tot juli 2018; de package verliest `objectid` en volledigheidsinformatie en de toegang is niet aantoonbaar algemeen. Niet geselecteerd. De vroegere uitgebreide proef blijft in de gitgeschiedenis; de #1222-fixtures zijn geen geaccepteerde aansluiting. +- **Hamburg:** de [actuele collectie](https://api.hamburg.de/datasets/v1/behindertenstellplaetze/collections?f=json) en [WFS](https://geodienste.hamburg.de/wfs_behindertenstellplaetze?REQUEST=GetFeature&SERVICE=WFS&VERSION=2.0.0&typename=de.hh.up%3Abehindertenstellplaetze) zijn onderzocht. Het endpoint van package 3.0.0 geeft 404; de nieuwe collectie bestaat maar de itemsrequests liepen bij deze proef vast. WFS leverde 936 unieke features en een afzonderlijke hits-query meldde 936, maar de normale response meldt `numberMatched=unknown` en `numberReturned=0`. Een alternatief protocol en CRS-verwerking zijn extra werk. De oude Hamburg-fixture bewijst geen huidige packagewerking. + +## Broncontrole na implementatie + +Offline tests bewijzen gedrag tegen bekende voorbeelden. Een periodieke begrensde live controle in #775 controleert daarnaast endpoint, velden, identiteit en volledigheid. Ook een HTTP 200 met gewijzigde betekenis kan een fout zijn. Bij een bronfout geen nieuwe complete levering publiceren; de laatste geaccepteerde gegevens blijven staan en de producent meldt de storing. De broncheck staat los van gewone package-CI. + +## Werkelijke producer → core-proef op 2026-09-14 + +De live export bevat 1.420 unieke bronrecords en 1.579 regimes, is 1.384.804 bytes groot en heeft SHA-256 `99a6744f950a5c4307d9851524a84790f4a9ff3bb704819d02bd976df1abcd31`. Leverings-ID: `7982ffb8-87d7-401e-8989-424a81284738`; ophaalstart: `2026-09-13T23:41:47.974385Z`. Dit is een andere representatie dan de oorspronkelijke onderzoeksresponse hierboven. + +PostGIS controleerde alle geometrieën op de afzonderlijke core-testdatabase. Alle liggen binnen de ingestelde bbox, maar `ST_IsValidReason` meldt een zelfdoorsnijding voor bron-ID's `114185488210`, `114187488001`, `118990486331`, `119459482101`, `119478482386`, `121780485119`, `123005483477`, `123773490356`, `123778490343` en `124156485525`. + +In die eerste proef is daarom niets uit deze levering gepubliceerd of gedeeltelijk geïmporteerd. De foutmelding identificeert alle betrokken bron-ID's. De producer garandeert structurele volledigheid; dat is geen garantie op geometrische geldigheid. Kleine synthetische voorbeelden testen het veilige publicatiepad, maar vervangen deze ontbrekende acceptatie niet. + +Vervolgkeuze van de eigenaar op 2026-09-14: beoordeelde geometrie-afleiding in core, met behoud van de oorspronkelijke brongeometrie. `ST_MakeValid` met expliciete methode `linework` levert voor alle tien een geldige MultiPolygon met twee delen op (totale oppervlakte circa 11,43–15,71 m²). Dit is geometrische bruikbaarheid, geen bewijs van de werkelijkheid op straat. Origineel, afleiding en afgeleid kaartpunt staan alleen samen op de beheerkaart. Op de publieke kaart verschijnt uitsluitend de marker. Geen tien records overslaan en alsnog `complete=true` verklaren; elke afleiding vereist review. + +## Afzonderlijke implementatiecontrole + +`composer ci:check` slaagde lokaal met 280 backendtests (1.167 assertions), drie frontendtests, linting, types en productiebuild. De gerichte importtests draaien ook op DDEV/PHP 8.4 met PostGIS. De frontendbuild draaide op de host omdat de lokaal geïnstalleerde native buildmodule voor macOS is; dezelfde `node_modules` in de Linux-container gebruiken is niet ondersteund. + +Een afzonderlijke operationele proef op `nipkaart_test` liet twee PHP-processen gelijktijdig dezelfde synthetische levering publiceren terwijl de datasetrij eerst vergrendeld was. Eén publicatie slaagde, de andere kreeg “Deze levering is al beoordeeld”; er bleef precies één gemeentelijk record bestaan. De browserproef controleerde desktop en 390px mobiel, de polygoon/kaartpuntweergave en daadwerkelijke goedkeuring van een synthetische wijziging van onbekende capaciteit naar nul. Dit bewijst implementatiegedrag, geen geaccepteerde Amsterdam-import of productiepublicatie. + +## Lokale keten na beoordeelde geometrie-afleiding + +Het oorspronkelijke producerbestand met bovenstaande SHA-256 is na deze wijziging volledig ter beoordeling aangeboden: 1.420 nieuwe records, tien afleidingen, intake en vergelijking in circa 0,85 seconde op de lokale testomgeving. Alle oorspronkelijke bronclaims zijn vergeleken met de gepubliceerde `source_record`-waarden en gelijk gebleven. De tien afleidingen behouden de bronbegrenzing; hun kleinste delen beslaan circa 0,00000044–0,00017 m². Alle tien opgeslagen markerpunten liggen binnen hun afgeleide vorm. Er ontstaan geen extra parkeerplaatsen of markers uit deze delen. + +De daadwerkelijke browserpublicatie op `nipkaart_test` is eerst zonder geometriebevestiging geprobeerd en geblokkeerd. Na technische beoordeling en expliciete bevestiging is de volledige levering gepubliceerd. Een gecontroleerde herhaling behield alle 1.420 identiteiten en inhoudstijdstippen; een gewijzigde testkopie toonde één gewijzigde en één ontbrekende bronrij, waarbij het ontbrekende record behouden bleef. Een volgende testkopie botste met een handmatig aangepast aantal en werd geblokkeerd. Deze varianten zijn testmutaties van het vastgelegde bestand, geen nieuwe gemeentelijke waarnemingen. + +De publieke `/map`-pagina is ook mobiel gecontroleerd: alleen bestaande parkeermarkers, geen bron- of afgeleide polygonen. Voor de visuele controle is de bestaande Google Hybride-laag gebruikt; de lokale Mapbox-laag laadde geen achtergrond. De publieke discoverygegevens bevatten identiteit, titel, coördinaten en afstand; de geometrieclaims blijven bij intake/review. Alleen daar worden de twee vormen getoond om het markerpunt te beoordelen. + +De uitgebreide proef hield aanvankelijk meerdere volledige imports tegelijk vast en overschreed de lokale PHP-limiet van 128 MiB. De afzonderlijke conflictproef slaagde met een piek van 108 MiB. Dit is geen capaciteitstest voor de volledige bestandslimiet van 32 MiB: benchmark geheugen en verwerkingsduur vóór grotere datasets of langlevende workers. Een mislukte poging bleef ongepubliceerd; er is geen dataset gedeeltelijk vervangen. + +Deze resultaten bewijzen de lokale technische keten met het vastgelegde bronbestand. De review van deze PR, hercontrole van bronvoorwaarden en eventuele publieke activering blijven afzonderlijke stappen. Er zijn geen productiegegevens of bucketinstellingen gewijzigd. diff --git a/docs/development/quality-checks.md b/docs/development/quality-checks.md index 67c420ca..27ad6691 100644 --- a/docs/development/quality-checks.md +++ b/docs/development/quality-checks.md @@ -69,3 +69,9 @@ Additional plugins were evaluated individually: agent output is optional and the Permanent spatial coverage lives in `ParkingLocationTest`, `ParkingDiscoveryTest`, `GeoPointTest` and `GeoBoundsTest`: all source models, generated SRID/coordinate order and bulk updates, metre distances, deterministic ties, inclusive bounds, antimeridian queries, public filtering, and invalid inputs. There is no application coverage-polygon/intersection API yet; no speculative polygon model or tests of bare PostGIS functions are introduced. Query-plan assertions are intentionally omitted: tiny transactional fixtures do not meaningfully predict planner choices, while exact query results are stable regression contracts. See [Pest TIA documentation](https://pestphp.com/docs/tia) for baseline storage, invalidation and replay behavior. + +## Municipal file intake + +`tests/Feature/MunicipalImportTest.php` controleert de daadwerkelijke intake, autorisatie, PostGIS-validatie, beoordeling, herhaling, bronvolgorde, bescherming van handmatige waarden en transactieherstel op de afzonderlijke PostgreSQL/PostGIS-testdatabase. Kleine inline voorbeelden vervangen de experimentele schema's, Opis-validator en fixturecorpus uit #1222. Core bevat geen Python-omgeving. + +Een geslaagde test bewijst geen geldige livebron of toestemming voor productiepublicatie. Zie de [uitvoering en herstelafspraken](data-import-contract.md#lokale-uitvoering-en-herstel) en de [afzonderlijke bronproef](data-import-pilot.md). De Amsterdamse export bevat tien zelfdoorsnijdende polygonen. De intake stelt daarvoor een afzonderlijke geometrie-afleiding voor die expliciete review vereist; onbruikbare afleidingen blokkeren de levering. De tests bewijzen behoud van het origineel, goedkeuring/afwijzing, herhaling en later herstel bij de bron. De publieke kaart blijft uitsluitend markers tonen. diff --git a/docs/product/data-foundation.md b/docs/product/data-foundation.md new file mode 100644 index 00000000..e7832fdb --- /dev/null +++ b/docs/product/data-foundation.md @@ -0,0 +1,194 @@ +# Europese databasis voor NIPKaart + +Actuele uitvoeringsgrens: eerst [één bruikbare bron en een voorlopige levering](../development/data-import-contract.md), daarna een lokale export en beoordeelde core-import. De technische uitwerkingen hieronder zijn richtinggevend voor later; PR #1222 is een onvoltooid prototype, geen definitief contract of bewijs van die keten. + +Status: overeengekomen richting met een voorgesteld technisch ontwerp; nog niet geïmplementeerd. Vastgelegd op 2026-09-08 naar aanleiding van de product- en architectuurgesprekken met de eigenaar. Technische defaults en open beslissingen zijn hieronder expliciet gemarkeerd. Dit document is geen bewijs van werkende imports, Europese dekking of productieacceptatie. + +De gekozen uitvoering is een zelfstandige batchaanpak: importrepositories plannen het ophalen en publiceren complete bestanden; core ontdekt en verwerkt die leveringen. Dit vervangt het eerdere voorstel waarin core opdrachten aan workers uitdeelde. + +## Leeswijzer + +- Dit document beschrijft doel, verantwoordelijkheden, aangesloten datasets, hybride gegevens en productgrenzen. +- [Batchimportcontract](../development/data-import-contract.md) beschrijft versieerbare bestanden, gereedmelding, verwerking en herstel. +- [Uitvoering en beheer](../development/data-foundation-delivery.md) bevat werkpakketten, acceptatiecriteria, hosting, beveiliging, migratie en open beslissingen. +- [Concrete techstack](../development/data-foundation-stack.md) koppelt het ontwerp aan runtimes, libraries, opslag, authenticatie en deployment. +- [CONTEXT.md](../../CONTEXT.md) bevat de gedeelde begrippen. Bestaande modelnamen blijven gelden; nieuwe begrippen betekenen niet dat er al overeenkomstige modellen of tabellen bestaan. + +## 1. Doel en aanleiding + +NIPKaart helpt mensen bruikbare toegankelijke parkeerplekken vinden. Europese dekking is het ontwerpuitgangspunt. Werkelijke dekking wordt per gebied en dataset aangetoond; een Europese architectuur is geen belofte dat ieder land volledig wordt gedekt. + +Kant-en-klare datasets zijn schaars, verschillen sterk en bevatten vaak onvoldoende informatie. De eigenaar heeft daarom naast het bouwen van Python-packages veel handmatig brononderzoek en kaartonderzoek gedaan en zelf plekken toegevoegd. NIPKaart moet de resulterende parkeerinformatie duurzaam vastleggen en door anderen laten aanvullen en onderhouden. Het onderzoeksproces zelf blijft buiten het platform. + +De basis ondersteunt drie manieren van vergaren: + +1. Gemeentelijke en andere datasets via herbruikbare bronpackages, API's en bestanden. +2. Handmatige aanlevering van gevonden plekken of verkregen bestanden, met hun relevante herkomst. Het voorafgaande onderzoek wordt buiten NIPKaart gedaan. +3. Communitybijdragen: nieuwe plekken, aanvullingen, bevestigingen, correcties en meldingen dat een plek verdwenen of tijdelijk onbruikbaar is. + +Imports en communitywaarnemingen dragen samen bij aan wat NIPKaart toont. Een herimport mag lokale beoordelingen en correcties niet wissen. Herkomst, onzekerheid en actualiteit moeten voor gebruikers begrijpelijk blijven. + +## 2. Bestaande situatie en grenzen + +De volgende bevindingen zijn gebaseerd op de in deze sessie gelezen code en documentatie; de draaiende oude productieomgeving is niet onderzocht. + +| Onderdeel | Bestaande situatie | Richting | +| --- | --- | --- | +| [Oude NIPKaart](https://github.com/klaasnicolaas/nipkaart) | Spotterbijdragen met beoordeling; afzonderlijke gemeentelijke en offstreetdata | Bron van productervaring, geen runtimebasis voor nieuwe imports | +| [Core](https://github.com/NIPKaart/core) | Laravel 13, PostgreSQL/PostGIS, aparte bronmodellen, communitybevestigingen, favorieten en gedeelde discoveryservice | Regie, beoordeling, publicatie en gebruikerservaring | +| [disabled-parking](https://github.com/NIPKaart/disabled-parking) | 15 stadsadapters; Python-packages en enkele directe downloads; oude MySQL-tabellen; eerst verwijderen en daarna uploaden | Gemeentelijke adapters en zelfstandig geplande batchuitvoering | +| [offstreet-parking](https://github.com/NIPKaart/offstreet-parking) | Amsterdam en Hamburg; packagegebruik, periodieke lus, directe MySQL-writes | Adapters en geplande levering voor voorzieningen en bezetting | +| Universele Python-packages | Gemeentespecifieke bronclients, bijvoorbeeld ODPAmsterdam en UDPHamburg | Zelfstandig bruikbaar houden, zonder NIPKaart-afhankelijkheid | + +Bekende aandachtspunten uit de importcode: sommige identiteit is afgeleid van coördinaten, Den Haag gebruikt een limiet van 300 zonder paginering in de adapter, de gemeentelijke runner verwijdert bestaande stadsrecords vóór publicatie van vervangers en de Amsterdamse offstreetadapter zet ontbrekende aantallen om naar nul. Deze bevindingen rechtvaardigen gerichte tests; ze bewijzen geen huidige productiedatalekken of dataverlies. + +De [bestaande PostgreSQL-afspraak](../development/postgresql.md#fresh-start-decision) is een nieuwe database. Er is geen opdracht om de gehele oude MySQL-database over te zetten. Nieuw opgebouwde bronidentiteit, communitygegevens en favorieten moeten vanaf de eerste nieuwe publicatie wel stabiel blijven. + +## 3. Besluiten uit het gesprek + +| Besluit | Consequentie | +| --- | --- | +| Europese dekking als uitgangspunt | Geen verplicht Nederlands CBS-nummer, postcodeformaat, provinciepatroon of telefooncode als identiteit | +| Universele packages blijven een zelfstandig open-dataproduct | Geen NIPKaart-publicatiestatus, database-ID's, importcredentials of businessregels in die packages | +| Adapters en batchuitvoering horen in de twee importrepositories | NIPKaart-vertaling en ophaalplanning gebeuren buiten de universele packages | +| Het bestandcontract scheidt verzamelen en verwerken | Importomgeving plant fetches; core ontdekt complete leveringen en beheert publicatie | +| Brononderzoek blijft buiten het platform | Alleen operationeel datasetbeheer; geen CRM, leads, contacthistorie of onderzoeksworkflow | +| Python levert bestanden, core publiceert parkeerinformatie | Beperkte opslagrechten voor producenten; geen coretoken of databaseverbinding | +| Datasets en communitykennis zijn beide nodig | Correcties op geïmporteerde plekken horen bij de basis | +| Code mag open zijn, de operatie is gecontroleerd | Private opslag, gescheiden producent-/consumerrechten en beheerde bulktoegang | +| Mobiel is een toekomstige productrichting | Verwerking centraal en herbruikbaar; geen mobiele app bouwen in deze eerste oplevering | +| Vertrouwen kan later verwerking versnellen | Nu onderbouwde bijdrage- en beslisgeschiedenis verzamelen; nog geen automatische karmadrempels | + +De batchgrens en private bucket voor automatische overdracht zijn gekozen. De eerste proef gebruikt één lokaal JSON-bestand. Werkpakket A beschrijft het voorlopige formaat; producer en core beproeven dit vóór het wordt vastgezet. Opslagprovider en voltooiingsmechanisme horen bij #1217. + +## 4. Verantwoordelijkheden + +```mermaid +flowchart TD + U[Universele Python-packages] --> A[Adapters in de twee importrepositories] + T[Planning in importomgeving] --> A + A --> B[Eén JSON-bestand] + B --> M[Private bucket na de lokale proef] + M --> C[Core ontdekt complete leveringen] + C --> V[Core: validatie en vergelijking] + R[Communitywaarnemingen en correcties] --> D[Beoordeling en publicatiebesluiten] + V --> D + D --> P[Gepubliceerde gegevens per bronmodel] + P --> Q[Gedeelde discovery: kaart en lijst] +``` + +| Laag | Verantwoordelijk voor | Niet verantwoordelijk voor | +| --- | --- | --- | +| Universeel package | Bronprotocol, pagina's ophalen, bronobjecten en bronfouten | NIPKaart-identiteit, moderatie of distributiebeleid | +| NIPKaart-adapter | Veldbetekenis vertalen, bron-ID behouden, filterscope en volledigheid rapporteren | Gemeentelijke bronwaarden stilzwijgend vervangen door lokale correcties | +| Python-batchrunner | Eigen sourceplanning, begrensd ophalen, complete bestanden afleveren en fetchfouten melden | Core aanroepen om werk te claimen of parkeerinformatie publiceren | +| Core | Toegelaten datasets, bestandsdiscovery, importhistorie, validatie, beoordeling, publicatie en uitblijvende leveringen signaleren | Bronfetches plannen, broncredentials beheren of onderzoeksworkflow aanbieden | +| Beheerder | Bron toelaten, uitzonderingen beoordelen, voorwaarden en kwaliteit vastleggen | Iedere normale herimport handmatig overtypen | + +Er zijn twee verschillende soorten achtergrondwerk: Python verzamelt brondata; Laravel verwerkt aanleveringen en besluiten. Python leest geen Laravel-queuetabellen en krijgt geen databasecredentials. + +Core bewaart de leveringsafspraak en voorbeelden; de producent en consumer gebruiken hetzelfde beproefde formaat. Een aparte schemarelease is geen voorwaarde voor de pilot. Gedeelde Python-uitvoeringslogica wordt pas losgetrokken wanneer een tweede repository die werkelijk nodig heeft. De universele bronclients blijven daarvan onafhankelijk. + +## 5. Aangesloten datasets beheren + +Onderzoek naar mogelijke bronnen, contact met gemeenten en onderzoeksnotities blijven buiten NIPKaart. De eigenaar heeft expliciet aangegeven geen CRM of leadbeheer te willen bouwen. Deze documentatie schrijft daarvoor ook geen ander hulpmiddel voor. + +Een dataset wordt in core geregistreerd zodra we deze aansluiten. Het beheer bevat aanbieder/titel, herkomst-URL, gebied, datacategorie, toegelaten opslagprefix/scope, voorwaarden/attributie, verwachte leveringsleeftijd en import-/publicatiestatus. Adapterinstellingen, broncredentials en fetchplanning staan in de importomgeving. Coretoestanden `configured`, `active`, `paused` en `retired` betreffen verwerking, niet het stopzetten van de externe runner. Een eerste proefimport hoort bij aansluiting. + +Beantwoord bij het voorbereiden van een aansluiting buiten het platform de volgende vragen en neem de operationeel relevante uitkomst in de datasetconfiguratie over: + +- inhoud: algemene toegankelijke parkeerplekken, persoonsgebonden plekken, garages/P+R of livebezetting; +- werkelijk gedekt gebied en uitsluitingen, niet alleen de naam van de gemeente; +- stabiele bron-ID's, wijzigingen, verwijderingen, paginering en brondatums; +- geometrie, coördinatenstelsel, aantallen, toegang en tijdsbeperkingen; +- actualiteit en verantwoordelijke bronhouder; +- licentie, attributie, opslag en hergebruik, inclusief eventuele bronvoorwaarden; +- technische toegang, snelheidslimieten, onderhoudskosten en bestaand universeel package. + +Een bronhouder kan meerdere datasets leveren; een dataset kan meerdere gebieden bedienen; hetzelfde gebied kan meerdere datasets hebben. Landen/regio's zijn interne referenties met lokale benamingen en externe codes als optionele mappings. Een adapter hardcodet geen interne country/province-ID's. Werkpakket B definieert hoe onbekende buitenlandse administratieve niveaus naar de huidige verplichte geografische relaties worden vertaald; maak geen fictieve provincies zonder expliciete mappingbeslissing. + +De bestaande Nederlandse, Belgische en Duitse adapters zijn kandidaten voor een gevarieerde pilot. Geen daarvan is automatisch geselecteerd of productieklaar. Landelijke registers zoals [RDW/NPR](https://www.nationaalparkeerregister.nl/open-parkeerdata), gemeentelijke portalen en eventueel [OpenStreetMap](https://www.openstreetmap.org/copyright) worden buiten het platform inhoudelijk en qua voorwaarden beoordeeld. Dekking in NIPKaart beschrijft aangesloten datasets en communitygegevens, niet een onderzoeksinventaris van alle Europese gemeenten. + +## 6. Hybride gegevens en identiteit + +### 6.1 Bronrecords en fysieke plekken + +`ParkingSpace`, `ParkingMunicipal` en `ParkingOffstreet` blijven afzonderlijke modellen en tabellen. Een bronrecord zegt wat één bron over een plek of voorziening meldt; het is niet vanzelf het volledige beeld van de fysieke plek. + +De basis voegt herkomst en koppelingen toe zonder deze modellen samen te voegen. Een koppeling verwijst naar een expliciet type en een bestaande ID. Een gemeentelijk punt en een communityplek kunnen na beoordeling dezelfde fysieke plek beschrijven. Een garage en een gehandicaptenvak binnen die garage hebben eerder een containmentrelatie dan een identiteitsovereenkomst. + +Elke bronrecordidentiteit is uniek binnen `(dataset, external_id)`. Externe ID's zijn ondoorzichtige strings; behoud voorloopnullen en de volledige waarde. Een coördinatenwijziging verandert de interne ID niet. Ontbrekende stabiele bron-ID's vereisen een expliciete, geteste strategie voor die adapter en beoordeling van onzekere matches; een afgeronde coördinatenhash is geen universele oplossing. + +De eerste pilot maakt nieuwe bronrecords aan of koppelt ze aan een bestaande bronidentiteit. Mogelijke matches met andere bronnen/community worden voorgesteld, niet automatisch samengevoegd. Beoordeelde koppelingen hebben een actor, datum, reden en ontkoppelgeschiedenis. Discovery mag pas dubbelen onderdrukken zodra broninformatie en bestaande favoriet/detailverwijzingen behouden blijven. Die UI-wijziging heeft eigen acceptatie onder #1170/#1172. + +### 6.2 Waarnemingen en correcties + +Een communitywaarneming beschrijft welke eigenschap iemand heeft vastgesteld, hoe en wanneer. Onderscheid veldbezoek, toegestane beeldbron, document of andere bron. Vandaag ingevoerd is niet hetzelfde als vandaag waargenomen. Bij ouder beeldmateriaal blijft de beelddatum apart van de registratiedatum, met onbekende datum expliciet toegestaan. + +Een correctievoorstel bevat doeltype/ID, gewijzigde velden, eerdere waarden of de versie waarop het voorstel is gebaseerd, voorgestelde waarden, reden, waarnemingsdatum en onderbouwing. Correcties mogen op geïmporteerde plekken worden aangebracht zonder een nieuwe dubbele communityplek te creëren. + +Een beoordeling accepteert, verwerpt of vraagt meer informatie en bewaart wie wat op welke onderbouwing besliste. De huidige publicatie-/bevestigingsstatussen worden hergebruikt waar hun betekenis overeenkomt; een voorstelstatus wordt geen tweede concurrerende status van een parkeerplek. + +Voor de eerste versie geldt een expliciete veldkeuze: een actieve, goedgekeurde lokale correctie gaat voor de actuele bronwaarde van dat veld. Core bewaart beide. Een nieuwe afwijkende bronwaarde heropent een conflict voor beoordeling maar wist de correctie niet. Wanneer een correctie wordt ingetrokken, wordt de laatste geldige bronwaarde weer de basis. Een verlopen of betwiste correctie vraagt herbeoordeling; automatisch terugvallen vereist later een expliciet beleid. + +Voorbeeld: een bron meldt twee vakken, een beoordeelde waarneming corrigeert dat naar één. Een nieuwe import met twee vakken houdt bronwaarde twee en gepubliceerde waarde één vast en registreert het conflict. Als de bron later ook één meldt, kan het conflict vervallen; de correctiegeschiedenis blijft bestaan. + +### 6.3 Actualiteit en verdwenen plekken + +Bewaar afzonderlijk: wanneer opgehaald, welke datum/versie de bron vermeldt, wanneer iets is waargenomen, wanneer beoordeeld en wanneer gepubliceerd. Een geslaagde import vernieuwt niet automatisch de waarnemingsdatum van ieder record. + +Een ontbrekend record uit een aantoonbaar volledige snapshot wordt `missing_from_source`, met datum en importreferentie. In de pilot leidt dit tot beoordeling. Het verdwijnen uit een delta, mislukte import of livebezettingsbatch heeft die betekenis niet. Een afgeronde beoordeling kan de plek als niet beschikbaar publiceren zonder de identiteit en favorieten te vernietigen. Herintroductie behoudt de identiteit en volgt opnieuw het publicatiebeleid. + +Actualiteit wordt per informatietype beoordeeld. Een verouderde bezettingsmeting maakt alleen de livebeschikbaarheid onbekend; zij verwijdert geen garage. Algemene vrije capaciteit, toegankelijke capaciteit en toegankelijke vrije plaatsen zijn afzonderlijke gegevens. Onbekend is nooit nul. + +## 7. Beheer- en gebruikersflows + +### Bron aansluiten + +1. Selecteer buiten het platform een geschikte dataset en registreer de aansluiting met inhoud, herkomst en voorwaarden. +2. Configureer package/adapter, scope en ophaalritme in de importrepo; leg in core de toegelaten levering en verwachte maximale leeftijd vast. +3. Produceer een proefbestand en laat core dat valideren zonder publicatie. +4. Bekijk aantallen, kaartsteekproef, afwijkingen, voorwaarden en mogelijke dubbelen. +5. Beoordeel de eerste publicatie; stel daarna een automatisch beleid binnen vastgelegde controles in. + +### Terugkerende import + +De importomgeving haalt volgens eigen planning op en levert een compleet bestand af in de private bucket. Het in #1217 gekozen mechanisme voorkomt dat core een gedeeltelijke upload verwerkt. Core ontdekt het bestand, valideert en vergelijkt. Normale wijzigingen mogen na pilotacceptatie automatisch publiceren; afwijkingen komen in de beoordelingslijst. Core toont uitblijvende leveringen als achterstand en houdt ontvangst, validatie en publicatie apart. De oorzaak van een fetchfout staat in de importomgeving. Ophalen pauzeren en corepublicatie pauzeren zijn afzonderlijke handelingen. + +### Bijdragen en onderhouden + +Een gebruiker kan een plek toevoegen of op een bestaande plek een concrete correctie indienen. Het formulier vraagt alleen relevante informatie en maakt onbekende waarden mogelijk. Bevestigen moet duidelijk maken wat wordt bevestigd. Beheerders krijgen een vergelijkbare oude/nieuwe weergave, herkomst en reden, met een toegankelijk alternatief voor kaartinteractie. + +Gerichte onderhoudstaken richten zich op oude waarnemingen, conflicten en ontbrekende dekking. Start met een beheerfilter en vrijwillige bevestigingsflow. Locatiegerichte pushberichten of tracking van gebruikers horen niet bij deze basis. + +Publieke resultaten tonen bruikbare herkomst, relevante datum en tekstuele onzekerheid. Interne configuratie, credentials en onbewerkte payloads worden niet doorgegeven. Basiszoeken blijft zonder account beschikbaar en alle essentiële taken hebben een toetsenbordbruikbare lijst/detailflow. + +## 8. Vertrouwen en mobiele toekomst + +Het door de eigenaar genoemde Flitsmeister-voorbeeld is inspiratie; de precieze werking daarvan is hier niet onderzocht of als specificatie overgenomen. + +De basis registreert bijdragen, onafhankelijke bevestigingen, beoordelingen en teruggedraaide besluiten. Later kan aantoonbaar betrouwbare bijdragegeschiedenis helpen bij het prioriteren of automatisch verwerken van laag-risicowijzigingen. Veel indienen, zelfbevestiging of eerder automatisch accepteren is geen onafhankelijk bewijs van juistheid. Voorkom een feedbacklus waarin automatische acceptatie direct nieuw vertrouwen oplevert. + +Vertrouwen in een persoon verschilt van zekerheid over een specifieke eigenschap. Een melding dat een plek verdwenen is vraagt een ander beleid dan een kleine beschrijvingscorrectie. Drempels, misbruikdetectie, bezwaar en verval worden pas ingevoerd nadat de handmatige flow voldoende beoordeelde voorbeelden heeft. Geen publieke ranglijst of karmascore in de eerste oplevering. + +Een toekomstige mobiele app gebruikt dezelfde toepassingsregels voor bijdragen, beoordeling en lezen. Het batchcontract is geen mobiele API. Mobiele authenticatie, offlinebijdragen, GPS, camera en notificaties worden afzonderlijk ontworpen; vandaag bouwen we een telefoonbruikbare webflow en herbruikbare toepassingslogica. + +## 9. Open code en beschermde dienstverlening + +De universele bronpackages blijven onafhankelijk bruikbaar. Ook adapter- en corecode mogen open blijven volgens hun gekozen licenties. Open code geeft geen toegang tot de beheerde installatie, workers, opgeslagen bronbestanden of private operationele gegevens. Onderzoeksgegevens worden buiten het platform beheerd. + +De waarde van NIPKaart ontstaat uit doorlopend onderzoek, beoordeelde koppelingen, actuele correcties, operationele betrouwbaarheid en community. Toegang tot samengestelde bulkdata is een afzonderlijke productkeuze. Rate limits en querygrenzen beperken misbruik maar garanderen niet dat zichtbare kaartinformatie niet verzameld wordt. + +Voorwaarden verschillen per bron. [OpenStreetMap](https://www.openstreetmap.org/copyright) gebruikt ODbL met attributie- en share-alikevoorwaarden; veronderstel daarom niet dat iedere samengestelde dataset exclusief kan blijven. De [Google Maps-voorwaarden](https://www.google.com/intl/en-US/help/terms_maps/) bevatten beperkingen op het hergebruiken van kaartinhoud en het opbouwen van andere kaartdatasets. De geschiktheid van Street View als structurele gegevensbron moet vóór integratie specifiek worden beoordeeld. Deze architectuur geeft daarvoor geen hergebruiktoestemming. De geraadpleegde voorwaarden zijn gecontroleerd op 2026-09-08; bewaar per bron de toepasselijke versie en toegestane handelingen. + +Leg voor iedere bron apart vast of ophalen, ruwe opslag, normalisatie, publieke weergave en bulkherdistributie zijn toegestaan, met vereiste attributie. Voor communitybijdragen moeten gebruiksrechten en bewaarbeleid nog worden vastgesteld. Dat zijn lanceerbeslissingen, geen impliciete gevolgen van een code-licentie. + +## 10. Eerste resultaat en volgorde + +Eenvoud is leidend: [de startscope](../development/data-foundation-delivery.md#eenvoud-als-uitgangspunt-voor-uitvoering) splitst de eerste keten in bestandverwerking, automatisering en correctiebehoud. Begin met één bron en expliciete adaptermapping; bouw gedeelde abstracties pas wanneer een tweede bron daar aanleiding toe geeft. De technische uitwerking beschrijft ook latere mogelijkheden en is geen opdracht om alle onderdelen vooraf te bouwen. + +Het eerste resultaat is een volledige keten met één bestaand Python-package: bron toelaten, zelfstandig ophalen, bestand gereedmelden, ontdekken, beoordelen en publiceren, een communitycorrectie accepteren en veilig herimporteren. Onderbroken uploads, core-uitval, dubbele leveringen en laat binnenkomende oudere batches moeten diezelfde keten doorstaan. + +Daarna volgen een inhoudelijk afwijkende gemeentelijke bron uit een ander land, een offstreetvoorzieningenbron en een aparte bezettingsstroom. Generieke CSV/GeoJSON-upload sluit vervolgens aan op dezelfde verwerking. Een generieke adapterbouwer, continentbrede bronselectie, publieke bulk-API, automatische karma en mobiele app zijn geen voorwaarden voor deze eerste keten. + +Deze keuze haalt het noodzakelijke bronregister, importhistorie en correctiebehoud naar de eerste databasis. Zij verfijnt de eerdere Horizon 2-indeling in de [feature-inventaris](feature-inventory.md); bestemmingzoeken en toegankelijkheid blijven productdoelen. Werkpakketten en toetsbare criteria staan in het [uitvoeringsplan](../development/data-foundation-delivery.md). diff --git a/docs/product/feature-inventory.md b/docs/product/feature-inventory.md index 459ec793..bbed9346 100644 --- a/docs/product/feature-inventory.md +++ b/docs/product/feature-inventory.md @@ -99,6 +99,8 @@ Accessibility is a product requirement across every stream. Map-only interaction ## Proposed delivery horizons +The [data foundation plan](data-foundation.md) refines this sequencing as of 2026-09-08: the minimum operational dataset registry, import history and correction preservation are part of the first data delivery, rather than waiting for Horizon 2. The [work packages](../development/data-foundation-delivery.md) define the implementation and acceptance gates. Broader coverage, automated contributor trust and mobile remain later work; source research/CRM is outside platform scope. + ### Horizon 1 — Relaunchable core - destination/address search @@ -152,4 +154,4 @@ Accessibility is a product requirement across every stream. Map-only interaction 7. Map interaction always has an accessible non-map counterpart for essential tasks. 8. Keep source/domain models separate and build shared read models for discovery. 9. Prefer useful, testable increments over a single product rewrite. -10. Avoid collecting sensitive personal information unless a concrete feature truly requires it. \ No newline at end of file +10. Avoid collecting sensitive personal information unless a concrete feature truly requires it. diff --git a/docs/product/trust-and-provenance.md b/docs/product/trust-and-provenance.md index 8a10c02d..b96224fd 100644 --- a/docs/product/trust-and-provenance.md +++ b/docs/product/trust-and-provenance.md @@ -2,6 +2,8 @@ NIPKaart combines information with very different trust characteristics. The UI must make those differences understandable instead of flattening everything into identical markers. +The [European data foundation](data-foundation.md), [batch import contract](../development/data-import-contract.md) and [delivery plan](../development/data-foundation-delivery.md) elaborate this direction as of 2026-09-08. Independent Python producers schedule collection and publish complete versioned files; core discovers, validates and publishes their information while preserving corrections. Core does not coordinate workers. Source research and CRM workflows remain outside the platform. These are design documents, not implemented capabilities. + ## Source classes ### Community (`ParkingSpace`) diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index acc6ccd9..62cdafa2 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,3 +1,4 @@ +import { index as municipalImports } from '@/actions/App/Http/Controllers/Admin/MunicipalImportController'; import { NavFooter } from '@/components/nav-footer'; import { NavUser } from '@/components/nav-user'; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; @@ -85,6 +86,7 @@ export function AppSidebar() { href: parkingMunicipal.index(), icon: icons.Building, }, + hasRole('admin') && { title: t('municipal_imports'), href: municipalImports(), icon: icons.FileInput }, can('parking-rule.view_any') && { title: t('rules'), href: parkingRules.index(), diff --git a/resources/js/components/map/card-location-marker.tsx b/resources/js/components/map/card-location-marker.tsx index 61bb62e3..6c8fdba7 100644 --- a/resources/js/components/map/card-location-marker.tsx +++ b/resources/js/components/map/card-location-marker.tsx @@ -9,7 +9,9 @@ type Props = { longitude: number; onChange?: (lat: number, lng: number) => void; draggable?: boolean; + scrollWheelZoom?: boolean; nearbySpaces?: ParkingSpace[]; + children?: React.ReactNode; }; const { BaseLayer, Overlay } = LayersControl; @@ -25,7 +27,7 @@ const NearbyParkingMarkers = React.memo(function NearbyParkingMarkers({ spaces } return {markers}; }); -export default function LocationMarkerCard({ latitude, longitude, onChange, draggable, nearbySpaces }: Props) { +export default function LocationMarkerCard({ latitude, longitude, onChange, draggable, nearbySpaces, children, scrollWheelZoom = true }: Props) { const isDraggable = draggable ?? typeof onChange === 'function'; return ( @@ -33,7 +35,7 @@ export default function LocationMarkerCard({ latitude, longitude, onChange, drag @@ -64,6 +66,7 @@ export default function LocationMarkerCard({ latitude, longitude, onChange, drag + {children} + +
+
+

{t('title')}

+

{t('intro')}

+
+
+
+
+

+ {t('deliveries')} +

+

{t('deliveries_hint')}

+
+ {imports.data.length === 0 ? ( +
+
+ ) : ( +
    + {imports.data.map((item) => ( +
  • + +
    +
    + {item.dataset_source?.name} + #{item.id} + + {t(`states.${item.state}`)} + +
    +

    + {t('retrieved')}{' '} + +

    +
    +
  • + ))} +
+ )} + {(imports.prev_page_url || imports.next_page_url) && ( + + )} +
+ +
+
+ + ); +} diff --git a/resources/js/pages/backend/municipal-imports/show.tsx b/resources/js/pages/backend/municipal-imports/show.tsx new file mode 100644 index 00000000..048d0463 --- /dev/null +++ b/resources/js/pages/backend/municipal-imports/show.tsx @@ -0,0 +1,354 @@ +import { index, show, update } from '@/actions/App/Http/Controllers/Admin/MunicipalImportController'; +import InputError from '@/components/input-error'; +import LocationMarkerCard from '@/components/map/card-location-marker'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import AppLayout from '@/layouts/app-layout'; +import { Form, Head, Link } from '@inertiajs/react'; +import type { MultiPolygon, Polygon } from 'geojson'; +import { ArrowDown, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, MapPin, TriangleAlert } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GeoJSON } from 'react-leaflet'; +import type { Dataset, Import } from './index'; + +type Claim = { external_id: string; street: string | null; number: number | null; geometry: Polygon; source_attributes: Record }; +type Derivation = { geometry: Polygon | MultiPolygon; reason: string; method: string; engine: string }; +type Row = { + external_id: string; + status: string; + fields: string[]; + conflicts: string[]; + before: Claim | null; + after: Claim | null; + current: Record | null; + geometry_derivation?: Derivation | null; + previous_geometry_derivation?: Derivation | null; + point?: { latitude: number; longitude: number }; +}; +type Props = { + import: Import; + dataset: Dataset; + review: { derivations: number; counts: Record; blockers: string[]; rows: Row[]; token: string }; + page: number; + pages: number; +}; + +export default function Show({ import: delivery, dataset, review, page, pages }: Props) { + const { t, i18n } = useTranslation('backend/municipal-imports'); + const [mapRecord, setMapRecord] = useState(null); + return ( + + +
+
+ +
+
+ {Object.entries(review.counts).map(([kind, count]) => ( +
+
{t(`counts.${kind}`)}
+
{count.toLocaleString(i18n.language)}
+
+ ))} +
+ {delivery.state === 'pending' && review.derivations > 0 && ( +
+
+ )} + {delivery.state === 'pending' && review.blockers.length > 0 && ( +
    + {review.blockers.map((message) => ( +
  • {message}
  • + ))} +
+ )} +
+ {t('review_guidance')} +
+

{t('restrictions')}

+

{t('missing_note')}

+
+
+ {delivery.state !== 'pending' &&

{t('current_comparison')}

} + {delivery.review_reason && ( +
+

{t('decision_reason')}

+ {delivery.reviewed_at && ( +

+ +

+ )} +

{delivery.review_reason}

+
+ )} +
+
+
+

+ {t('records')} +

+

{t('records_hint')}

+
+ +
+
+ {review.rows.map((row) => ( +
{ + if (!event.currentTarget.open && mapRecord === row.external_id) { + setMapRecord(null); + } + }} + > + + +
+ {row.geometry_derivation && ( +
+

{t('derived')}

+

{t('derived_note')}

+
+ {t('derivation_details')} +
+                                                    {JSON.stringify(row.geometry_derivation, null, 2)}
+                                                
+
+
+ )} + {row.point && row.after && ( +
+ + {mapRecord === row.external_id && ( +
+

{t('map_note')}

+ {row.geometry_derivation &&

{t('geometry_legend')}

} + + {row.geometry_derivation && ( + + )} + + +
+ )} +
+ )} + {row.fields.length > 0 && ( +

+ {t('changed_fields')}:{' '} + {row.fields.map((field) => t(`fields.${field}`, { defaultValue: field })).join(', ')} +

+ )} + {row.conflicts.length > 0 && ( +

+ {t('protected_fields')}:{' '} + {row.conflicts.map((field) => t(`fields.${field}`, { defaultValue: field })).join(', ')} +

+ )} +
+ {(['before', 'after'] as const).map((side) => ( +
+

{t(side)}

+

+ {row[side] ? `${t('capacity')}: ${row[side]?.number ?? t('unknown')}` : t('no_source_value')} +

+
+ {t('source_details')} +
+                                                        {JSON.stringify(row[side], null, 2)}
+                                                    
+
+
+ ))} +
+ {row.previous_geometry_derivation && ( +
+ {t('previous_derivation')} +
+                                                {JSON.stringify(row.previous_geometry_derivation, null, 2)}
+                                            
+
+ )} + {row.current && ( +
+ {t('current')} +
+                                                {JSON.stringify(row.current, null, 2)}
+                                            
+
+ )} +
+
+ ))} +
+
+ + {delivery.state === 'pending' && ( +
+

+ {t('decision')} +

+

{t('decision_hint')}

+
+ {({ errors, processing }) => ( + <> + + +