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

@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -21,13 +21,14 @@ final class Company extends Model
protected static function booted(): void
{
static::addGlobalScope(new OrganisationScope());
self::addGlobalScope(new OrganisationScope);
}
protected $fillable = [
'organisation_id',
'name',
'type',
'kvk_number',
'contact_first_name',
'contact_last_name',
'contact_email',

View File

@@ -18,6 +18,7 @@ final class CompanyFactory extends Factory
'organisation_id' => Organisation::factory(),
'name' => fake('nl_NL')->company(),
'type' => fake()->randomElement(['supplier', 'partner', 'agency', 'venue', 'other']),
'kvk_number' => fake()->numerify('########'),
'contact_first_name' => fake('nl_NL')->firstName(),
'contact_last_name' => fake('nl_NL')->lastName(),
'contact_email' => fake()->companyEmail(),

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Adds a nullable kvk_number column to companies. Crewli's binding-target
* registry references company.kvk_number as a B2B identity-key candidate
* (per WS-6 RFC binding registry); the column needed to exist on the
* model first.
*
* Nullable: not every Company has a registered KvK (foreign companies,
* partners, agencies). Identity-key validation runs at publish time per
* RequiresIdentityKeyBinding guard, not at the schema level.
*
* No backfill pre-launch the table is short and seed-driven.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('companies', function (Blueprint $table): void {
$table->string('kvk_number')->nullable()->after('type');
$table->index('kvk_number');
});
}
public function down(): void
{
Schema::table('companies', function (Blueprint $table): void {
$table->dropIndex(['kvk_number']);
$table->dropColumn('kvk_number');
});
}
};

View File

@@ -111,6 +111,7 @@ CREATE TABLE `companies` (
`organisation_id` char(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`type` enum('supplier','partner','agency','venue','other') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`kvk_number` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`contact_first_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`contact_last_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`contact_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
@@ -120,6 +121,7 @@ CREATE TABLE `companies` (
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `companies_organisation_id_index` (`organisation_id`),
KEY `companies_kvk_number_index` (`kvk_number`),
CONSTRAINT `companies_organisation_id_foreign` FOREIGN KEY (`organisation_id`) REFERENCES `organisations` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -1747,3 +1749,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (152,'2026_04_27_10
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (153,'2026_04_27_100001_backfill_form_field_options',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (154,'2026_04_27_100002_drop_form_field_options_json_columns',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (155,'2026_04_28_100000_restore_default_crowd_type_id_foreign_key',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (156,'2026_04_28_140000_add_kvk_number_to_companies_table',3);

View File

@@ -57,7 +57,7 @@ final class FormFieldBindingMigrationTest extends TestCase
// validation-rules-backfill, create-validation-rules) +
// 2 WS-6 migrations (action-failures, apply-status) +
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 16.
$this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_bindings'));
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
@@ -119,7 +119,7 @@ final class FormFieldBindingMigrationTest extends TestCase
public function test_rollback_reconstructs_json_and_drops_table(): void
{
// Walk back the full WS-5d + WS-5c + WS-6 + WS-5b + WS-5a stack (16 migrations).
$this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful();
[$fieldAId] = $this->seedFieldsWithBindingJson();
[$libAId] = $this->seedLibraryWithBindingJson();
@@ -134,7 +134,7 @@ final class FormFieldBindingMigrationTest extends TestCase
// the pre-WS-5b state (conditional-logic, validation-rules, configs
// and options tables gone, validation_rules + options JSON columns
// reappear on source tables; binding contract intact).
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_options'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions'));

View File

@@ -49,7 +49,7 @@ final class ConditionalLogicBackfillTest extends TestCase
// create-options + WS-5c drop-cl-col + WS-5c backfill-cl
// migrations to land in the conditional-logic JSON-era state with
// no relational form_field_options table yet.
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic'));
$fieldId = $this->seedFieldWithJson([
@@ -170,7 +170,7 @@ final class ConditionalLogicBackfillTest extends TestCase
]);
// Roll back only the backfill migration — writes the JSON back.
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful();
$reconstructed = DB::table('form_fields')
->where('id', $fieldId)
@@ -203,7 +203,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_top_level_key_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful();
$this->seedFieldWithJson([
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]],
@@ -216,7 +216,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_comparison_operator_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful();
$this->seedFieldWithJson([
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],

View File

@@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase
// Roll back 4 WS-5c migrations + 2 WS-6 migrations + 5 WS-5b
// migrations = 11, to get the pre-WS-5b state where the JSON column
// still exists on form_fields / form_field_library.
$this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 14])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$fieldId = $this->seedField([

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(),

View File

@@ -53,7 +53,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// validation-rules-backfill + create-validation-rules) = 14.
// Brings us to the pre-WS-5b state: validation_rules JSON column
// present, no relational tables for WS-5b/c/d.
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
@@ -114,7 +114,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TAG_PICKER',
@@ -138,7 +138,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -165,7 +165,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -182,7 +182,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'BOOLEAN',
@@ -201,7 +201,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// full-back-then-full-forward cycle — rolling back all WS-5b
// migrations restores the pre-WS-5b state (columns present on
// source tables; validation rules relational table gone).
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
[$numberId] = $this->seedFields();
$this->artisan('migrate')->assertSuccessful();
@@ -216,7 +216,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// Roll back WS-5b fully → column reappears and carries canonical JSON
// reconstructed from the relational rows.
$this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$field = DB::table('form_fields')->where('id', $numberId)->first();

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Models;
use App\Models\Company;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
final class CompanyKvkNumberTest extends TestCase
{
use RefreshDatabase;
public function test_company_has_kvk_number_attribute(): void
{
$company = Company::factory()->create(['kvk_number' => '12345678']);
$this->assertSame('12345678', $company->fresh()->kvk_number);
}
public function test_kvk_number_is_nullable(): void
{
$company = Company::factory()->create(['kvk_number' => null]);
$this->assertNull($company->fresh()->kvk_number);
}
public function test_kvk_number_is_indexed(): void
{
$row = DB::selectOne(
'SELECT INDEX_NAME FROM information_schema.STATISTICS '
."WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'companies' AND COLUMN_NAME = 'kvk_number'"
);
$this->assertNotNull($row, 'Expected index on companies.kvk_number');
}
}