feat(companies): add kvk_number column for B2B identity binding (WS-6)

WS-6 binding-target registry references company.kvk_number as a B2B
identity-key candidate. The column needed to exist on the model
before the registry could legitimately reference it. Nullable
because not every Company has a registered KvK (foreign companies,
partners, agencies); identity-key publish guards enforce presence
where required, not at schema level.

Changes:
- New migration `2026_04_28_140000_add_kvk_number_to_companies_table`
  adds nullable string column + index after `type`.
- Company::$fillable expanded.
- CompanyFactory generates an 8-digit KvK by default.
- CompanyKvkNumberTest covers attribute persistence, nullability,
  and information_schema-verified index existence.
- SCHEMA.md → v2.8 with the new column row + indexes line.
- Schema dump regenerated (CI fast-path).

Migration step counts in 5 backfill tests bumped +1 (the new
migration sits at the top of the migration stack):
  - FormFieldBindingMigrationTest:           18→19, 16→17
  - ConditionalLogicBackfillTest:             7→8
  - FormFieldConfigBackfillAndDropTest:      13→14
  - FormFieldOptionsBackfillTest:             3→4
  - FormFieldValidationRuleBackfillTest:     16→17

Refs: WS-6 sessie 3a binding-target drift audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 20:17:56 +02:00
parent ccc9dc905b
commit 383b4fc5a3
11 changed files with 116 additions and 29 deletions

View File

@@ -47,7 +47,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Roll back only the backfill migration (latest WS-5d step).
// Leaves the form_field_options table in place, JSON columns
// present on the source tables, and snapshots in pre-WS-5d shape.
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->assertTrue(Schema::hasTable('form_field_options'));
$this->assertTrue(Schema::hasColumn('form_fields', 'options'));
@@ -136,7 +136,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_rollback_reconstructs_json_columns_and_snapshots(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
[$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson();
$submissionId = $this->seedSubmissionWithSnapshot($selectId);
@@ -149,7 +149,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Step back over only the backfill migration → JSON columns repopulate
// and snapshots revert to flat-string-array shape.
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->assertSame(0, DB::table('form_field_options')->count());
$select = DB::table('form_fields')->where('id', $selectId)->first();
@@ -168,7 +168,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']);
$this->expectException(\RuntimeException::class);
@@ -178,7 +178,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_contains_non_string_entry(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
['label' => 'A'],
@@ -192,7 +192,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_is_object_shape(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
'XS' => 'Extra small',
@@ -206,7 +206,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_translations_length_mismatch(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([
'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3
]));
@@ -218,7 +218,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_non_string_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([
'de' => ['options' => ['Klein', 42]],
]));
@@ -230,7 +230,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_oversized_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([
'de' => ['options' => [str_repeat('x', 256)]],
]));
@@ -242,7 +242,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedTemplateWithSnapshotRaw([
'fields' => [[
'id' => (string) Str::ulid(),