),
@@ -828,8 +828,8 @@ public function updateFarm(
/**
* Soft-deletes a farm's entity on ODK Central, then soft-deletes the local FarmEntity
- * row (not hard-deleted - keeps it around for any future FK references, e.g. from
- * FarmSurveyData once this is wired up at cutover).
+ * row (not hard-deleted - keeps it around for any future FK references from submission
+ * data, once submissions are linked to the farm they describe).
*/
public function deleteFarm(FarmEntity $farmEntity): void
{
diff --git a/database/migrations/06_locations/2024_06_11_06_104524_create_farms_table.php b/database/migrations/06_locations/2024_06_11_06_104524_create_farms_table.php
deleted file mode 100644
index b218b31a..00000000
--- a/database/migrations/06_locations/2024_06_11_06_104524_create_farms_table.php
+++ /dev/null
@@ -1,50 +0,0 @@
-id();
- $table->foreignId('owner_id')->constrained('teams')->cascadeOnDelete()->cascadeOnUpdate();
- $table->foreignId('location_id')->constrained()->cascadeOnDelete()->cascadeOnUpdate();
- $table->string('team_code'); // should be unique per team
-
- // identifiers
- $table->json('identifiers')->nullable(); // identifiers - by default, we consider this personally identifying information of the farm.
-
- // location
- $table->decimal('latitude', 11, 8)->nullable();
- $table->decimal('longitude', 11, 8)->nullable();
- $table->integer('altitude')->nullable();
- $table->decimal('accuracy', 9, 4)->nullable();
-
- // Probably temporary until we figure out a generalised approach
- $table->boolean('household_form_completed')->default(false)->comment('Is household form completed for this farm?');
- $table->boolean('fieldwork_form_completed')->default(false)->comment('Is fieldwork form completed for this farm?');
-
- $table->boolean('household_pilot_completed')->default(false)->comment('Is household form completed for this farm with a pilot-test submission?');
- $table->boolean('fieldwork_pilot_completed')->default(false)->comment('Is fieldwork form completed for this farm with a pilot test submission?');
-
- $table->boolean('refused')->default(false);
-
- $table->json('properties')->nullable(); // other properties;
-
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('farms');
- }
-};
diff --git a/database/migrations/07_survey_data/2024_08_12_11_121716_create_farm_survey_data_table.php b/database/migrations/07_survey_data/2024_08_12_11_121716_create_farm_survey_data_table.php
deleted file mode 100644
index 74912f36..00000000
--- a/database/migrations/07_survey_data/2024_08_12_11_121716_create_farm_survey_data_table.php
+++ /dev/null
@@ -1,59 +0,0 @@
- 8126). Changing some columns to TEXT or BLOB may help. In current row format, BLOB prefix of 0 bytes is stored inline."
-
- // Google searched below stackoverflow thread:
- // MySQL: Error Code: 1118 Row size too large (> 8126). Changing some columns to TEXT or BLOB
- // https://stackoverflow.com/questions/22637733/mysql-error-code-1118-row-size-too-large-8126-changing-some-columns-to-te
-
- // Solution:
- // Run below SQL in TablePlus
- // SET GLOBAL innodb_strict_mode = 0;
-
- /**
- * Run the migrations.
- */
- public function up(): void
- {
- Schema::create('farm_survey_data', function (Blueprint $table) {
- $table->id();
-
- $table->json('properties')->nullable();
-
- $table->text('start')->nullable();
- $table->text('end')->nullable();
- $table->text('today')->nullable();
- $table->text('deviceid')->nullable();
- $table->text('inquirer')->nullable();
-
- $table->text('household_survey_date')->nullable();
- $table->unsignedBigInteger('farm_id')->nullable();
-
- // location
- $table->decimal('latitude', 11, 8)->nullable();
- $table->decimal('longitude', 11, 8)->nullable();
- $table->integer('altitude')->nullable();
- $table->decimal('accuracy', 9, 4)->nullable();
- $table->text('gps_location_alt')->nullable();
-
- $table->foreignId('submission_id')->nullable();
-
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('farm_survey_data');
- }
-};
diff --git a/database/migrations/07_survey_data/2024_08_12_12_111301_create_crops_table.php b/database/migrations/07_survey_data/2024_08_12_12_111301_create_crops_table.php
deleted file mode 100644
index 29aea215..00000000
--- a/database/migrations/07_survey_data/2024_08_12_12_111301_create_crops_table.php
+++ /dev/null
@@ -1,134 +0,0 @@
-id();
-
- $table->foreignId('farm_survey_data_id')->nullable();
- $table->json('properties')->nullable();
-
- $table->text('primary_crop_id')->nullable();
- $table->text('primary_crop_name')->nullable();
- $table->integer('crop_varities')->nullable();
- $table->decimal('primary_crop_area', 24, 6)->nullable();
- $table->decimal('primary_crop_area_ha', 24, 6)->nullable();
- $table->text('primary_crop_practices')->nullable();
-
- $table->decimal('area_annual_mono', 24, 6)->nullable();
- $table->decimal('area_perennial_mono', 24, 6)->nullable();
- $table->decimal('area_afroforestry', 24, 6)->nullable();
- $table->decimal('area_burning', 24, 6)->nullable();
- $table->decimal('area_cover', 24, 6)->nullable();
- $table->decimal('area_rotation', 24, 6)->nullable();
- $table->decimal('area_fallow', 24, 6)->nullable();
- $table->decimal('area_hedgerows', 24, 6)->nullable();
- $table->decimal('area_garden', 24, 6)->nullable();
- $table->decimal('area_intercrop', 24, 6)->nullable();
- $table->decimal('area_landclearing', 24, 6)->nullable();
- $table->decimal('area_mulch', 24, 6)->nullable();
- $table->decimal('area_natural', 24, 6)->nullable();
- $table->decimal('area_pollinator', 24, 6)->nullable();
- $table->decimal('area_push_pull', 24, 6)->nullable();
- $table->decimal('area_other_practice', 24, 6)->nullable();
-
- $table->decimal('density_annual_mono', 20, 2)->nullable();
- $table->decimal('density_perennial_mono', 20, 2)->nullable();
- $table->decimal('density_afroforestry', 20, 2)->nullable();
- $table->decimal('density_burning', 20, 2)->nullable();
- $table->decimal('density_cover', 20, 2)->nullable();
- $table->decimal('density_rotation', 20, 2)->nullable();
- $table->decimal('density_fallow', 20, 2)->nullable();
- $table->decimal('density_hedgerows', 20, 2)->nullable();
- $table->decimal('density_garden', 20, 2)->nullable();
- $table->decimal('density_intercrop', 20, 2)->nullable();
- $table->decimal('density_landclearing', 20, 2)->nullable();
- $table->decimal('density_mulch', 20, 2)->nullable();
- $table->decimal('density_natural', 20, 2)->nullable();
- $table->decimal('density_pollinator', 20, 2)->nullable();
- $table->decimal('density_push_pull', 20, 2)->nullable();
- $table->decimal('density_other_practice', 20, 2)->nullable();
-
- $table->text('agroforestry_crops')->nullable();
- $table->text('cover_crops')->nullable();
- $table->text('rotation_crops')->nullable();
- $table->text('garden_crops')->nullable();
- $table->text('intercrop_crops')->nullable();
- $table->text('push_pull_crops')->nullable();
- $table->text('agroforestry_trees_n')->nullable();
- $table->text('hedgerows_trees_n')->nullable();
- $table->text('homegarden_trees_n')->nullable();
- $table->text('agroforestry_trees_diversity')->nullable();
- $table->text('hedgerows_trees_diversity')->nullable();
- $table->text('homegarden_trees_diversity')->nullable();
- $table->text('agroforestry_trees_spatial')->nullable();
- $table->text('hedgerows_trees_spatial')->nullable();
- $table->text('homegarden_trees_spatial')->nullable();
- $table->text('yield_unit')->nullable();
-
- $table->text('yield_unit_kg_conversion')->nullable();
- $table->text('yield_unit_label')->nullable();
- $table->text('yield_unit_label_english')->nullable();
- $table->text('total_yield')->nullable();
- $table->text('yield_kg')->nullable();
- $table->text('yield_weight_area')->nullable();
- $table->text('yield_kg_ha')->nullable();
-
- $table->decimal('yield_annual_mono', 20, 2)->nullable();
- $table->decimal('yield_perennial_mono', 20, 2)->nullable();
- $table->decimal('yield_afroforestry', 20, 2)->nullable();
- $table->decimal('yield_burning', 20, 2)->nullable();
- $table->decimal('yield_cover', 20, 2)->nullable();
- $table->decimal('yield_rotation', 20, 2)->nullable();
- $table->decimal('yield_fallow', 20, 2)->nullable();
- $table->decimal('yield_hedgerows', 20, 2)->nullable();
- $table->decimal('yield_garden', 20, 2)->nullable();
- $table->decimal('yield_intercrop', 20, 2)->nullable();
- $table->decimal('yield_landclearing', 20, 2)->nullable();
- $table->decimal('yield_mulch', 20, 2)->nullable();
- $table->decimal('yield_natural', 20, 2)->nullable();
- $table->decimal('yield_pollinator', 20, 2)->nullable();
- $table->decimal('yield_push_pull', 20, 2)->nullable();
- $table->decimal('yield_other_practice', 20, 2)->nullable();
-
- $table->text('primary_crop_use')->nullable();
- $table->text('primary_crop_use_other')->nullable();
- $table->text('yield_sums')->nullable();
-
- $table->decimal('amount_own_consumption', 20, 2)->nullable();
- $table->decimal('amount_livestock_consumption', 20, 2)->nullable();
- $table->decimal('amount_consumer', 20, 2)->nullable();
- $table->decimal('amount_coop', 20, 2)->nullable();
- $table->decimal('amount_market', 20, 2)->nullable();
- $table->decimal('amount_trader', 20, 2)->nullable();
- $table->decimal('amount_gift', 20, 2)->nullable();
- $table->decimal('amount_wasted', 20, 2)->nullable();
- $table->decimal('amount_other_use', 20, 2)->nullable();
- $table->decimal('consumer_price', 20, 2)->nullable();
- $table->decimal('coop_price', 20, 2)->nullable();
- $table->decimal('market_price', 20, 2)->nullable();
- $table->decimal('trader_price', 20, 2)->nullable();
-
- $table->foreignId('submission_id')->nullable();
-
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('crops');
- }
-};
diff --git a/database/migrations/07_survey_data/2024_08_12_12_111310_create_livestocks_table.php b/database/migrations/07_survey_data/2024_08_12_12_111310_create_livestocks_table.php
deleted file mode 100644
index e4337b80..00000000
--- a/database/migrations/07_survey_data/2024_08_12_12_111310_create_livestocks_table.php
+++ /dev/null
@@ -1,43 +0,0 @@
-id();
-
- $table->foreignId('farm_survey_data_id')->nullable();
- $table->json('properties')->nullable();
-
- $table->unsignedInteger('livestock_id')->nullable();
- $table->text('livestock_name')->nullable();
- $table->text('livestock_other_check')->nullable();
- $table->text('livestock_label')->nullable();
- $table->integer('livestock_breeds')->nullable();
- $table->integer('number_raised')->nullable();
- $table->text('livestock_filter')->nullable();
- $table->text('livestock_uses')->nullable();
- $table->text('livestock_use_other')->nullable();
-
- $table->foreignId('submission_id')->nullable();
-
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('livestocks');
- }
-};
diff --git a/database/migrations/2026_07_31_000001_drop_legacy_farm_tables.php b/database/migrations/2026_07_31_000001_drop_legacy_farm_tables.php
new file mode 100644
index 00000000..4ac21731
--- /dev/null
+++ b/database/migrations/2026_07_31_000001_drop_legacy_farm_tables.php
@@ -0,0 +1,19 @@
+delete();
// datasets mentioned in Data Structure excel file
- $farmSurveyDataset = Dataset::create(['name' => 'Farm Survey Data', 'parent_id' => NULL, 'primary_key' => 'id', 'entity_model' => FarmSurveyData::class]);
- Dataset::create(['name' => 'Crops', 'parent_id' => $farmSurveyDataset->id, 'primary_key' => 'id', 'entity_model' => Crop::class]);
- Dataset::create(['name' => 'Farms', 'parent_id' => $farmSurveyDataset->id, 'primary_key' => 'id', 'entity_model' => Farm::class]);
- Dataset::create(['name' => 'Livestock', 'parent_id' => $farmSurveyDataset->id, 'primary_key' => 'id', 'entity_model' => Livestock::class]);
+ $farmSurveyDataset = Dataset::create(['name' => 'Farm Survey Data', 'parent_id' => null, 'primary_key' => 'id']);
+ Dataset::create(['name' => 'Crops', 'parent_id' => $farmSurveyDataset->id, 'primary_key' => 'id']);
+ Dataset::create(['name' => 'Livestock', 'parent_id' => $farmSurveyDataset->id, 'primary_key' => 'id']);
Dataset::create(['name' => 'Locations', 'parent_id' => $farmSurveyDataset->id, 'primary_key' => 'id', 'entity_model' => Location::class]);
}
diff --git a/docs/change-logs/farm-crud-cutover-and-import-chaining.md b/docs/change-logs/farm-crud-cutover-and-import-chaining.md
new file mode 100644
index 00000000..ec5f65b7
--- /dev/null
+++ b/docs/change-logs/farm-crud-cutover-and-import-chaining.md
@@ -0,0 +1,126 @@
+# Change log: Farm CRUD cutover + combined-import chaining
+
+Implements [docs/plans/farm-crud-cutover-and-import-chaining.md](../plans/farm-crud-cutover-and-import-chaining.md), which closes Phase 6 of [odk-entities-farm-crud.md](../plans/odk-entities-farm-crud.md) and finding M13 of [2026-07-19-map-location-to-entity-list-attribute.md](../code-reviews/2026-07-19-map-location-to-entity-list-attribute.md).
+
+Landed as three commits on `remove-extra-holpa-items`:
+
+| Commit | Scope |
+| --- | --- |
+| `e6bd0a7` | Part A — remove the legacy Farm model, `FarmResource` and the HOLPA survey-data models |
+| `d249bff` | Part A8 — reclaim the `farms` URL slug for `FarmEntityResource` |
+| `573a267` | Part B — chain `FarmEntityImport` after `LocationImport` |
+
+---
+
+## Part A — legacy farm and HOLPA survey-data removal
+
+### Moved (shared assets, not legacy)
+
+- `FarmResource/Widgets/FarmListHeaderWidget.php` → `FarmEntityResource/Widgets/FarmListHeaderWidget.php` (namespace updated; `ListFarmEntities` import updated).
+- Its Blade view moved too, `filament/app/resources/farm-resource/widgets/` → `.../farm-entity-resource/widgets/`, so the widget's `$view` no longer points into a `farm-resource` tree that has no resource behind it.
+- `.../farm-resource/pages/import-locations-and-farms.blade.php` → `.../farm-entity-resource/pages/import-locations-and-farm-entities.blade.php`; `ImportLocationsAndFarmEntities::$view` updated and both empty `farm-resource/` view directories removed.
+
+### Deleted
+
+- `FarmResource.php`, `FarmResource/Pages/ListFarms.php`, `FarmResource/Pages/ImportLocationsAndFarms.php` and the whole `FarmResource/` directory.
+- `app/Models/SampleFrame/Farm.php`, `app/Imports/FarmImport.php`, `app/Events/FarmImportCompleted.php`, `app/Policies/FarmPolicy.php`.
+- `app/Livewire/DataCollection/DataCollectionByFarm.php` + its Blade view (zero references anywhere).
+- `HelperService::findFarmLocationDetails()` (no callers) and its now-unused `Farm` / `Str` imports.
+- The whole `app/Models/SurveyData/` namespace — `FarmSurveyData`, `Crop`, `Livestock` — plus `app/Models/Interfaces/RepeatModel.php` and both now-empty directories.
+
+### Edited
+
+| File | Change |
+| --- | --- |
+| `app/Providers/AppServiceProvider.php` | dropped `Gate::policy(Farm::class, FarmPolicy::class)` and both imports |
+| `app/Models/Team.php` | removed `farms()`; `pilotProgress` now `$this->xlsforms()->whereHas('xlsformVersions.submissions')->exists()`; `dataCollectionProgress` the same with `->where('test_data', false)` |
+| `app/Models/SampleFrame/Location.php` | removed `farms()` and the three farm-completion attributes; `farmEntities()` and `farms_all_count` kept |
+| `app/Livewire/DataCollection/DataCollectionByLocation.php` | the "Farm Counts" `ColumnGroup` collapsed to a single `farms_all_count` "Farms" column |
+| `app/Filament/Admin/Widgets/DataCollectedWidget.php` | removed the "Farms surveyed" stat |
+| `app/Exports/DataExport/DatasetExport.php` | removed the `Farm` import and `@var` docblock; runtime behaviour untouched |
+| `app/Filament/Tables/Actions/ImportFarmsAction.php` | `model_type => FarmEntity::class` (was `Farm::class`) — closes the cosmetic gap recorded in the earlier plan |
+| `database/seeders/Prep/DatasetSeeder.php` | the `Farms` row deleted; `Farm Survey Data` / `Crops` / `Livestock` rows kept but their `entity_model` keys dropped |
+| `CLAUDE.md` | `Models/SampleFrame/` now lists `FarmEntity`; the `Models/SurveyData/` line removed |
+
+Comments across `FarmEntityResource`, `ListFarmEntities`, `ImportLocationsAndFarmEntities`, `FarmEntityImport`, `FarmEntityPolicy`, `LocationLevelResource`, `OdkFarmEntityService` and `survey-locations-index.blade.php` that described coexistence with the legacy resource, or that named the deleted classes, were rewritten or dropped.
+
+### Behaviour change worth knowing
+
+`Team::pilotProgress` previously meant "a farm has at least one submission"; it now means "the team has at least one submission". Nothing has linked submissions to farms since HOLPA submission processing was removed in `4a93d71`, so the old expression could only ever return `false` — the new one is strictly closer to what the dashboard tile is trying to say. The same reasoning applies to `dataCollectionProgress`, which read `farms.household_form_completed` / `fieldwork_form_completed`, columns nothing has written for just as long.
+
+### Schema
+
+- Deleted the four `create` migrations (`create_farms_table`, `create_farm_survey_data_table`, `create_crops_table`, `create_livestocks_table`) so fresh installs never build the tables.
+- Added `2026_07_31_000001_drop_legacy_farm_tables.php`, which `dropIfExists()`es `crops`, `livestocks`, `farm_survey_data` and `farms` so existing dev databases converge. A no-op on a fresh install. No FK constraints existed between any of them.
+- No column-level surgery was needed: `farm_survey_data.farm_id` disappeared with its table, and nothing outside the four tables pointed at them.
+- **Note for later:** existing dev databases still hold seeded `Dataset` rows whose `entity_model` column names the now-deleted classes. Nothing reads `entity_model` (the only readers — `Dataset::globalEntries` / `teamEntries` / `databaseTable` — have no consumers), so no data migration was written. Those strings are stale labels, not live pointers.
+
+### What deliberately survived
+
+`App\Exports\DataExport\FarmSurveyDataExport`, `FarmSurveyDatasetExport` and `DatasetExport` share names with the deleted models but are driven entirely by `Dataset` rows looked up **by name** plus generic `Entity`/`EntityValue` rows written by `OdkSubmissionService`. They never referenced the model classes. That is why the `Farm Survey Data`, `Crops` and `Livestock` seeder rows had to stay — `FarmSurveyDataExport` `abort(500)`s without the parent row.
+
+### A8 — the `farms` slug
+
+With the old resource gone, `FarmEntityResource::$slug` went from `farm-entities` back to `farms`, restoring `app/{tenant}/location-levels/farms`. Class names unchanged. `survey-locations-index.blade.php` already resolves the URL through `FarmEntityResource::getUrl()` and needed no edit; only the two tests that hit the route by path moved. Kept as its own commit so it can be reverted independently of the deletions.
+
+---
+
+## Part B — chaining the combined import
+
+### Root cause
+
+`ImportLocationsAndFarmEntities::save()` called `Excel::import()` twice and discarded both return values. For a `ShouldQueue` + `WithChunkReading` importer, `ChunkReader::read()` builds `(new QueueImport($import))->chain([...ReadChunk jobs, AfterImportJob])` and returns it inside a `PendingDispatch` that dispatches on destruction — so the page produced **two independent chains on one queue**. Ordering was then whatever the worker pool decided, and `FarmEntityImport::rules()` validates its location column with `Rule::exists('locations', 'code')`, so with more than one worker the farm import could fail wholesale with per-row "location does not exist" errors. It only worked because a single FIFO worker happened to preserve dispatch order.
+
+`Bus::chain([$locationImport, $farmImport])` would not have fixed it: a chained job that itself calls `Excel::import()` returns as soon as its chunks are *queued*, so the next outer link starts immediately.
+
+### `app/Jobs/QueueFarmEntityImport.php` (new)
+
+A plain `ShouldQueue` job holding `array $data` and `int $importId`. `handle()` resolves the `Import` record and runs `Excel::import(new FarmEntityImport($this->data), $import->getFirstMediaPath())` — the media path is resolved at run time rather than serialised as an absolute path built at request time. `failed()` writes the exception onto the `Import` record in the same shape `FarmEntityImport`'s own `ImportFailed` handler uses, so a failure to even *start* the farm import shows on the imports table instead of being silent.
+
+### `ImportLocationsAndFarmEntities::save()`
+
+```php
+Excel::queueImport(new LocationImport($data), $locationImport->getFirstMediaPath())
+ ->appendToChain(new QueueFarmEntityImport($farmData, $farmImport->id));
+```
+
+`Queueable::appendToChain()` adds to the chain `ChunkReader` already built, after its trailing `AfterImportJob` — `Queueable::chain()` would have *replaced* `$chained` and thrown away every `ReadChunk` job. `PendingDispatch::__call()` forwards the call to the underlying `QueueImport`.
+
+The plan called for `Excel::import()` behind an `instanceof PendingDispatch` guard to satisfy phpstan. `Excel::queueImport()` was used instead: it is the same call narrowed to the `ShouldQueue` case and is annotated `PendingDispatch` on the facade, so the guard — which was never a real branch — is gone. One `@phpstan-ignore-next-line` remains on `appendToChain()`, which phpstan cannot see through `PendingDispatch::__call()`.
+
+`$farmData` is a copy of `$data` with `$data['level']` unset: that key holds a whole `LocationLevel` **model**, and `SerializesModels` does not reduce models nested inside array properties. `FarmEntityImport` never reads it.
+
+On `QUEUE_CONNECTION=sync` the chain runs inline in order, so local and test behaviour is unchanged apart from the ordering guarantee.
+
+### `LocationImport` failure propagation
+
+If the location import fails the chain aborts and `QueueFarmEntityImport` never runs, leaving the farm `Import` row empty — reading as "nothing happened" rather than "skipped". `save()` now also passes `$data['dependent_import_id']`, and `LocationImport`'s `ImportFailed` handler calls a new `failDependentImport()` that writes an explanatory error onto that record. The key is optional: `LocationImport` is still used standalone from `LocationLevelResource\Pages\ViewLocationLevel`.
+
+---
+
+## Tests
+
+| File | Change |
+| --- | --- |
+| `tests/Feature/Crud/AppPanelCrudTest.php` | the `App panel CRUD — Farm` block replaced by `App panel CRUD — FarmEntity`: the list page loads, and `DeleteAction` soft-deletes the row *and* issues the `DELETE .../datasets/Farm_Summary/entities/{uuid}` call to Central. The delete path had no coverage before. |
+| `tests/Feature/Smoke/AppPanelTest.php` | "farms list loads" repointed at the new resource |
+| `tests/Feature/Exports/FarmSurveyDataExportTest.php` (new) | pins the plan's claim that the submissions export is name-driven, not model-driven: it must still build after the `SurveyData` models and their `entity_model` pointers were removed |
+| `tests/Feature/Imports/ImportLocationsAndFarmEntitiesChainingTest.php` (new) | the M13 regression test — submitting the wizard under `Queue::fake()` pushes exactly **one** `QueueImport` whose `chained` tail deserialises to a `QueueFarmEntityImport` carrying the farm `Import` id and no `level` key. Plus `QueueFarmEntityImport::handle()`/`failed()`, and `LocationImport`'s failure path with and without `dependent_import_id`. |
+
+The smoke test needed no `Http::fake()` treatment beyond what was already there: a team with no `OdkProject` makes `refreshFromCentral()` short-circuit before reaching Central. (The plan predicted this would come from `resolveEntityListName()` returning `null`; that method is now hardcoded to `'Farm_Summary'`, so the `findOdkDataset()` null-project branch is what does it.)
+
+## Verification
+
+- `./vendor/bin/pint` — clean.
+- `./vendor/bin/phpstan analyse` — 164 errors, down from a 346-error baseline on the branch point. Nothing new in the touched application files; the 10 in the new test file are the usual Pest `$this->` property noise this suite already carries.
+- `./vendor/bin/pest` — 167 passed, 377 assertions.
+- The plan's verification grep returns only the intended survivors: the three `FarmSurveyData*Export` classes, their two call sites (`XlsformsTableView`, `ExportDataAction`), the seeder's `Dataset` names and the new export test.
+
+## Still open
+
+Manual verification from the plan has **not** been done and is left for whoever runs the branch:
+
+1. Part A — app-panel survey dashboard (both progress tiles), Survey Locations → "List of farms", the location-levels "# of Farms" column, the admin dashboard without the "Farms surveyed" stat, and the submissions export download from the xlsforms table.
+2. Part B — a real ODK Central project with **at least two queue workers**, importing a combined spreadsheet whose farms reference locations that do not yet exist locally; then the negative case, where the location half fails validation and the farm `Import` record should show the "skipped" error.
+
+Follow-ups carried forward from the plan are unchanged: location-deletion behaviour after the `nullOnDelete` change was never manually retested; submission → farm linking is still unimplemented (and should be built on `Dataset`/`Entity`/`EntityValue`, not new per-repeat-group tables); Create/Edit form fields still don't match the Farm Registration XLSForm; entities deleted directly on Central are not mirrored locally; a cleared entity property reappears in the Edit form as a blank row.
diff --git a/docs/plans/farm-crud-cutover-and-import-chaining.md b/docs/plans/farm-crud-cutover-and-import-chaining.md
index 30e7cff3..460480ec 100644
--- a/docs/plans/farm-crud-cutover-and-import-chaining.md
+++ b/docs/plans/farm-crud-cutover-and-import-chaining.md
@@ -1,6 +1,6 @@
# Plan: Finish the ODK-Entities farm CRUD cutover (remove legacy Farm + HOLPA survey-data models) + chain the combined import
-**Status: Not Started**
+**Status: Completed** — see [docs/change-logs/farm-crud-cutover-and-import-chaining.md](../change-logs/farm-crud-cutover-and-import-chaining.md). All steps A1–A8 and B1–B4 landed in three commits on `remove-extra-holpa-items`. Two deviations from the plan as written, both recorded in the change log: A1 also moved the `FarmListHeaderWidget` Blade view (a third shared asset the plan didn't list), and B2 uses `Excel::queueImport()` rather than `Excel::import()` behind an `instanceof PendingDispatch` guard — same call, correctly typed, so the guard was unnecessary. The manual verification steps (5 and 6, which need a real ODK Central project and two queue workers) have not been performed.
Wraps up [odk-entities-farm-crud.md](odk-entities-farm-crud.md) — its Phase 6 (cutover) and the one known open finding from [2026-07-19-map-location-to-entity-list-attribute.md](../code-reviews/2026-07-19-map-location-to-entity-list-attribute.md) (M13). Two independent parts; either can land first, but Part A deletes `FarmResource\Pages\ImportLocationsAndFarms`, so doing Part A first avoids fixing the concurrency bug in a file that is about to be deleted.
diff --git a/resources/views/filament/app/clusters/location-levels/resources/farm-resource/pages/import-locations-and-farms.blade.php b/resources/views/filament/app/clusters/location-levels/resources/farm-entity-resource/pages/import-locations-and-farm-entities.blade.php
similarity index 100%
rename from resources/views/filament/app/clusters/location-levels/resources/farm-resource/pages/import-locations-and-farms.blade.php
rename to resources/views/filament/app/clusters/location-levels/resources/farm-entity-resource/pages/import-locations-and-farm-entities.blade.php
diff --git a/resources/views/filament/app/pages/survey-locations/survey-locations-index.blade.php b/resources/views/filament/app/pages/survey-locations/survey-locations-index.blade.php
index 3fb6fa72..4a36dd3e 100644
--- a/resources/views/filament/app/pages/survey-locations/survey-locations-index.blade.php
+++ b/resources/views/filament/app/pages/survey-locations/survey-locations-index.blade.php
@@ -3,9 +3,6 @@
use App\Filament\App\Clusters\LocationLevels\Resources\FarmEntityResource;
use App\Filament\App\Pages\SurveyDashboard;
- // Points at the new ODK-Entities-backed Farms page - the original FarmResource is
- // kept around (unlinked) for comparison during the migration. See
- // docs/plans/odk-entities-farm-crud.md.
$farmUrl = FarmEntityResource::getUrl();
$surveyDashboardUrl = SurveyDashboard::getUrl();
diff --git a/resources/views/filament/app/resources/farm-resource/widgets/farm-list-header-widget.blade.php b/resources/views/filament/app/resources/farm-entity-resource/widgets/farm-list-header-widget.blade.php
similarity index 100%
rename from resources/views/filament/app/resources/farm-resource/widgets/farm-list-header-widget.blade.php
rename to resources/views/filament/app/resources/farm-entity-resource/widgets/farm-list-header-widget.blade.php
diff --git a/resources/views/livewire/data-collection/data-collection-by-farm.blade.php b/resources/views/livewire/data-collection/data-collection-by-farm.blade.php
deleted file mode 100644
index 9cf15d2d..00000000
--- a/resources/views/livewire/data-collection/data-collection-by-farm.blade.php
+++ /dev/null
@@ -1,5 +0,0 @@
-
- @if($visible)
- {{ $this->table }}
- @endif
-
diff --git a/tests/Feature/Crud/AppPanelCrudTest.php b/tests/Feature/Crud/AppPanelCrudTest.php
index 93f4782d..fd897517 100644
--- a/tests/Feature/Crud/AppPanelCrudTest.php
+++ b/tests/Feature/Crud/AppPanelCrudTest.php
@@ -1,12 +1,12 @@
Http::response(['token' => 'fake-token'], 200),
+ // ListFarmEntities::mount() self-heals the local OdkDataset row from Central;
+ // a 404 short-circuits it to an empty live feed without needing an OData fake.
+ '*/datasets/Farm_Summary' => Http::response([], 404),
+ '*/entities/*' => Http::response([], 200),
+ ]);
+
$this->team = Team::factory()->create();
+ OdkProject::create(['id' => 1, 'owner_type' => Team::class, 'owner_id' => $this->team->id, 'name' => 'Project 1']);
+
$this->user = createAppUser($this->team);
$this->actingAs($this->user);
});
@@ -125,12 +135,13 @@
$this->get("/app/{$this->team->id}/location-levels/farms")->assertOk();
});
- test('can bulk delete farm', function () {
+ test('can delete farm entity via table action', function () {
withAppTenant($this->team);
$level = new LocationLevel;
$level->name = 'Village';
$level->owner_id = $this->team->id;
+ $level->has_farms = true;
$level->save();
$location = Location::create([
@@ -140,16 +151,21 @@
'code' => 'tv1',
]);
- $farm = Farm::create([
+ $farmEntity = FarmEntity::create([
'owner_id' => $this->team->id,
'location_id' => $location->id,
'team_code' => 'farm-001',
+ 'odk_uuid' => 'uuid-farm-001',
+ 'odk_version' => 1,
]);
- livewire(ListFarms::class)
- ->callTableBulkAction(DeleteBulkAction::class, [$farm]);
+ livewire(ListFarmEntities::class)
+ ->callTableAction(DeleteAction::class, $farmEntity);
+
+ $this->assertSoftDeleted('farm_entities', ['id' => $farmEntity->id]);
- $this->assertDatabaseMissing('farms', ['id' => $farm->id]);
+ Http::assertSent(fn ($request) => $request->method() === 'DELETE'
+ && str_contains($request->url(), 'datasets/Farm_Summary/entities/uuid-farm-001'));
});
});
diff --git a/tests/Feature/Exports/FarmSurveyDataExportTest.php b/tests/Feature/Exports/FarmSurveyDataExportTest.php
new file mode 100644
index 00000000..a411195f
--- /dev/null
+++ b/tests/Feature/Exports/FarmSurveyDataExportTest.php
@@ -0,0 +1,28 @@
+team = Team::factory()->create();
+});
+
+test('submissions export builds for a team with no submissions', function () {
+ $sheets = (new FarmSurveyDataExport($this->team))->sheets();
+
+ expect($sheets)->not->toBeEmpty();
+
+ $raw = Excel::raw(new FarmSurveyDataExport($this->team), ExcelWriter::XLSX);
+
+ expect($raw)->not->toBeEmpty();
+});
diff --git a/tests/Feature/Imports/ImportLocationsAndFarmEntitiesChainingTest.php b/tests/Feature/Imports/ImportLocationsAndFarmEntitiesChainingTest.php
new file mode 100644
index 00000000..81f788cf
--- /dev/null
+++ b/tests/Feature/Imports/ImportLocationsAndFarmEntitiesChainingTest.php
@@ -0,0 +1,211 @@
+fillForm([
+ 'upload' => ['fake-upload-uuid' => $upload],
+ 'header_columns' => [
+ 'village_code' => 'village_code',
+ 'village_name' => 'village_name',
+ 'farm_code' => 'farm_code',
+ 'family_name' => 'family_name',
+ ],
+ 'code_column' => 'village_code',
+ 'name_column' => 'village_name',
+ 'level' => $level,
+ 'user_id' => $user->id,
+ 'owner_id' => $team->id,
+ 'location_level_id' => $level->id,
+ 'location_code_column' => 'village_code',
+ 'farm_code_column' => 'farm_code',
+ 'farm_identifiers' => ['family_name'],
+ 'farm_properties' => [],
+ ])
+ ->call('save')
+ ->assertHasNoFormErrors();
+}
+
+describe('ImportLocationsAndFarmEntities chains the farm import after the location import', function () {
+
+ beforeEach(function () {
+ Http::fake();
+ Storage::fake(config('filesystems.default'));
+
+ $this->team = Team::factory()->create();
+ $this->user = createAppUser($this->team);
+ $this->actingAs($this->user);
+
+ $this->level = LocationLevel::create([
+ 'owner_id' => $this->team->id,
+ 'name' => 'Village',
+ 'has_farms' => true,
+ ]);
+
+ withAppTenant($this->team);
+ });
+
+ test('one queued chain is pushed, with the farm import as its tail', function () {
+ submitCombinedImportWizard($this->team, $this->user, $this->level);
+
+ Queue::assertPushed(QueueImport::class, 1);
+
+ Queue::assertPushed(QueueImport::class, function (QueueImport $job) {
+ $tail = unserialize(end($job->chained));
+
+ return $tail instanceof QueueFarmEntityImport;
+ });
+ });
+
+ test('the chained farm job carries the farm Import id and no LocationLevel model', function () {
+ submitCombinedImportWizard($this->team, $this->user, $this->level);
+
+ $farmImport = Import::where('model_type', FarmEntity::class)->sole();
+
+ Queue::assertPushed(QueueImport::class, function (QueueImport $job) use ($farmImport) {
+ /** @var QueueFarmEntityImport $tail */
+ $tail = unserialize(end($job->chained));
+
+ return $tail->importId === $farmImport->id
+ && $tail->data['import_id'] === $farmImport->id
+ && ! array_key_exists('level', $tail->data);
+ });
+ });
+
+});
+
+describe('QueueFarmEntityImport', function () {
+
+ beforeEach(function () {
+ Http::fake();
+ Storage::fake(config('filesystems.default'));
+
+ $this->team = Team::factory()->create();
+ });
+
+ test('handle() imports the farm Import record media with a FarmEntityImport', function () {
+ $upload = combinedImportSpreadsheet();
+
+ $import = Import::create(['team_id' => $this->team->id, 'model_type' => FarmEntity::class]);
+ $import->addMedia(Storage::path($upload))->preservingOriginal()->toMediaCollection();
+
+ Excel::fake();
+
+ (new QueueFarmEntityImport(['owner_id' => $this->team->id], $import->id))->handle();
+
+ Excel::assertImported($import->getFirstMediaPath(), fn ($import) => $import instanceof FarmEntityImport);
+ });
+
+ test('failed() records the exception on the farm Import record', function () {
+ $import = Import::create(['team_id' => $this->team->id, 'model_type' => FarmEntity::class]);
+
+ (new QueueFarmEntityImport([], $import->id))->failed(new RuntimeException('queue exploded'));
+
+ expect($import->fresh()->errors->first()['errors'])->toBe(['queue exploded']);
+ });
+
+});
+
+describe('LocationImport failure propagation', function () {
+
+ beforeEach(function () {
+ Http::fake();
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+
+ $this->locationImportRecord = Import::create(['team_id' => $this->team->id, 'model_type' => 'Location']);
+ });
+
+ test('a failed location import explains the skip on the dependent farm Import record', function () {
+ $farmImportRecord = Import::create(['team_id' => $this->team->id, 'model_type' => FarmEntity::class]);
+
+ $import = new LocationImport(locationImportData($this->team, $this->user, [
+ 'import_id' => $this->locationImportRecord->id,
+ 'dependent_import_id' => $farmImportRecord->id,
+ ]));
+
+ $handler = $import->registerEvents()[ImportFailed::class];
+ $handler(new ImportFailed(new RuntimeException('bad location file')));
+
+ expect($this->locationImportRecord->fresh()->errors->first())->toBe('bad location file');
+ expect($farmImportRecord->fresh()->errors->first()['errors'][0])
+ ->toContain('skipped because the location import it depends on failed')
+ ->toContain('bad location file');
+ });
+
+ test('a standalone location import failure works without a dependent import', function () {
+ $import = new LocationImport(locationImportData($this->team, $this->user, [
+ 'import_id' => $this->locationImportRecord->id,
+ ]));
+
+ $handler = $import->registerEvents()[ImportFailed::class];
+ $handler(new ImportFailed(new RuntimeException('bad location file')));
+
+ expect($this->locationImportRecord->fresh()->errors->first())->toBe('bad location file');
+ });
+
+});
+
+function locationImportData(Team $team, User $user, array $overrides = []): array
+{
+ return array_merge([
+ 'header_columns' => [
+ 'village_code' => 'village_code',
+ 'village_name' => 'village_name',
+ ],
+ 'code_column' => 'village_code',
+ 'name_column' => 'village_name',
+ 'owner_id' => $team->id,
+ 'user_id' => $user->id,
+ ], $overrides);
+}
diff --git a/tests/Feature/Smoke/AppPanelTest.php b/tests/Feature/Smoke/AppPanelTest.php
index 4f1d0024..b6a4a27a 100644
--- a/tests/Feature/Smoke/AppPanelTest.php
+++ b/tests/Feature/Smoke/AppPanelTest.php
@@ -111,6 +111,8 @@
->assertOk();
});
+ // The team has no OdkProject, so ListFarmEntities::mount()'s refreshFromCentral()
+ // short-circuits to an empty live feed without reaching ODK Central at all.
test('farms list loads', function () {
$this->actingAs($this->user)
->get("/app/{$this->team->id}/location-levels/farms")