OCTO Ops Help User guides and product documentation

2026 08 28 Warehouse Phase0 Platform Foundation

On this page 11

Warehouse Phase 0 — Platform Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up the four platform-foundation MongoDB collections (task queue, event log, alert rules, notifications) and the Store.warehouseRole classification field that every Phase 1+ warehouse capability depends on, plus seed the Phase 1 feature-flag keys — with zero behavior change for any existing warehouse workflow.

Architecture: Four new, independent Mongo-backed Eloquent models under a new App\Models\Warehouse\* namespace (a peer to the existing App\Models\Store\* / App\Models\Stock\* domain folders — these are new platform concepts with no legacy namespace to mirror, so they don't belong in either). Each follows the exact structural pattern already used by every other model in this module (BelongsToOrganization for tenant scoping, \OwenIt\Auditing\Auditable for audit logging, SoftDeletes where the collection is mutable). WarehouseEvent is the one deliberate exception — no SoftDeletes, and update/delete are blocked at the model level, because "append-only" is meant to be an enforced guarantee, not a comment. Store.warehouseRole is added as a plain nullable string field (not a native Eloquent enum cast — this codebase's own App\Enums\* classes are used via manual ::from()/::tryFrom() in business logic, never as $casts entries, so a new field follows that proven pattern instead of introducing an untested one). Thirteen new ModuleSub rows extend the existing seeder so Phase 1's tracks each reserve an id/key under module 10, seeded with menu => false so none of them appear in any sidebar until the track that owns it flips its own row to menu => true when it actually ships a route.

Correction (post-implementation, final review): the sentence above originally claimed these rows plug into the Team.modulesAllowed / roles permission system "already generic enough to need no code changes of its own." That's wrong. app/Support/Modules.php only resolves visibility at the module level (modules.{moduleKey}) — nothing in this codebase reads a ModuleSub.key for gating today. These 13 rows are id/key reservations only; they carry no enforcement. Phase 1's RBAC track must build submodule-level resolution in Modules.php from scratch (or an equivalent mechanism) — it is new scope for that track, not infrastructure this plan already delivered.

Tech Stack: Laravel 13 / PHP 8.4, mongodb/laravel-mongodb (MongoDB\Laravel\Eloquent\Model), owen-it/laravel-auditing, PHPUnit 12.

Spec: .ai/modules/warehouse.md §10.1 (Warehouse role / purpose classification — Tier 1), §11.1 Group 1 (Task & Event Platform), §11.2 (Store.warehouseRole), §12.1 (Phase 0 — Platform foundation), §12.2 (Phase 1 track list, for the feature-flag keys this phase seeds).

Global Constraints

  • Additive only — every new collection and field is new; nothing existing is renamed, retyped, or dropped. A tenant with every new flag off must be byte-identical to today's production behavior (spec §11 design principle 1, §12.2 exit criteria).
  • Preserve every existing collection, model name, field name, customID sequence, status code, route name, and web/API response shape exactly as documented in .ai/modules/warehouse.md §1.2.
  • No new routes/controllers in this phase — these are backend platform collections with no UI until Phase 1+ consumes them (confirmed against routes/web/warehouse.php / routes/api/v1/warehouse.php, neither needs changes here).
  • No factories for Mongo models in this codebase's tests — construct directly (Model::create([...])), matching every existing tests/Feature/Warehouse/* test.
  • PHP 8.4: always use curly braces for control structures, constructor property promotion, explicit return types on every method.
  • Run vendor/bin/pint --dirty --format agent after any PHP file change, before considering a task done.
  • The existing 578-line PHPUnit warehouse suite (tests/Feature/Warehouse/*) must stay green throughout — run the full suite as the final task, not just the tests this plan adds.

File Structure

  • Create app/Models/Warehouse/WarehouseTask.php — the task queue (put-away/pick/replenish/count/pack, later wave/backflush/etc.).
  • Create app/Models/Warehouse/WarehouseEvent.php — append-only event log.
  • Create app/Models/Warehouse/WarehouseAlertRule.php — alert rule configuration.
  • Create app/Models/Warehouse/WarehouseNotification.php — generated notifications.
  • Create app/Enums/WarehouseRole.php — the raw_material/wip/finished_goods/retail_store/distribution/other classification.
  • Modify app/Models/Store/Store.php — add warehouseRole to the docblock (no cast entry needed — plain string).
  • Modify database/seeders/ModulesSeeder.php — add 13 new ModuleSub rows (ids 1013–1025) for Phase 1's feature-flag keys.
  • Create tests/Unit/Enums/WarehouseRoleTest.php — enum value/label/options coverage.
  • Create tests/Feature/Warehouse/PlatformFoundationTest.php — every other test in this plan.

Task 1: WarehouseRole enum

Files:

  • Create: app/Enums/WarehouseRole.php
  • Test: tests/Unit/Enums/WarehouseRoleTest.php

Interfaces:

  • Produces: App\Enums\WarehouseRole — backed string enum with cases RawMaterial, Wip, FinishedGoods, RetailStore, Distribution, Other; label(): string; options(): array<int, array{value: string, label: string}>.

  • Step 1: Write the failing test

php
<?php

namespace Tests\Unit\Enums;

use App\Enums\WarehouseRole;
use PHPUnit\Framework\TestCase;

class WarehouseRoleTest extends TestCase
{
    public function test_cases_have_the_expected_string_values(): void
    {
        $this->assertSame('raw_material', WarehouseRole::RawMaterial->value);
        $this->assertSame('wip', WarehouseRole::Wip->value);
        $this->assertSame('finished_goods', WarehouseRole::FinishedGoods->value);
        $this->assertSame('retail_store', WarehouseRole::RetailStore->value);
        $this->assertSame('distribution', WarehouseRole::Distribution->value);
        $this->assertSame('other', WarehouseRole::Other->value);
    }

    public function test_label_returns_a_human_readable_string_for_every_case(): void
    {
        $this->assertSame('Raw Material', WarehouseRole::RawMaterial->label());
        $this->assertSame('Work In Progress', WarehouseRole::Wip->label());
        $this->assertSame('Finished Goods', WarehouseRole::FinishedGoods->label());
        $this->assertSame('Retail Store', WarehouseRole::RetailStore->label());
        $this->assertSame('Distribution', WarehouseRole::Distribution->label());
        $this->assertSame('Other', WarehouseRole::Other->label());
    }

    public function test_options_returns_one_value_label_pair_per_case(): void
    {
        $options = WarehouseRole::options();

        $this->assertCount(6, $options);
        $this->assertSame(['value' => 'raw_material', 'label' => 'Raw Material'], $options[0]);
    }

    public function test_tryfrom_returns_null_for_an_unknown_value(): void
    {
        $this->assertNull(WarehouseRole::tryFrom('not-a-role'));
    }
}
  • Step 2: Run test to verify it fails

Run: php artisan test --compact tests/Unit/Enums/WarehouseRoleTest.php Expected: FAIL — Class "App\Enums\WarehouseRole" not found.

  • Step 3: Write the enum
php
<?php

namespace App\Enums;

/**
 * Classifies what a `Store` (warehouse) is actually used for — raw-material
 * intake, work-in-progress staging, finished-goods, a retail store, or a
 * distribution center. Nothing before this enum distinguished warehouse
 * *purpose*; every Phase 1+ role-specific behavior in
 * `.ai/modules/warehouse.md` §10.5 branches on it. Existing warehouses have
 * no value set (nullable `Store.warehouseRole`) and behave exactly as
 * today — treat an unset value as equivalent to `Other`.
 */
enum WarehouseRole: string
{
    case RawMaterial = 'raw_material';
    case Wip = 'wip';
    case FinishedGoods = 'finished_goods';
    case RetailStore = 'retail_store';
    case Distribution = 'distribution';
    case Other = 'other';

    public function label(): string
    {
        return match ($this) {
            self::RawMaterial => 'Raw Material',
            self::Wip => 'Work In Progress',
            self::FinishedGoods => 'Finished Goods',
            self::RetailStore => 'Retail Store',
            self::Distribution => 'Distribution',
            self::Other => 'Other',
        };
    }

    /**
     * @return array<int, array{value: string, label: string}>
     */
    public static function options(): array
    {
        return array_map(fn (self $role) => ['value' => $role->value, 'label' => $role->label()], self::cases());
    }
}
  • Step 4: Run test to verify it passes

Run: php artisan test --compact tests/Unit/Enums/WarehouseRoleTest.php Expected: PASS (4 tests).

  • Step 5: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Enums/WarehouseRole.php tests/Unit/Enums/WarehouseRoleTest.php
git commit -m "Warehouse: add WarehouseRole enum for warehouse-purpose classification"

Task 2: Store.warehouseRole field

Files:

  • Modify: app/Models/Store/Store.php:17-40 (docblock + class body — no $casts entry, see Architecture)
  • Test: tests/Feature/Warehouse/PlatformFoundationTest.php (new file)

Interfaces:

  • Consumes: App\Enums\WarehouseRole (Task 1) — used only for validation/labels in this task, not stored as a cast.

  • Produces: Store::create(['warehouseRole' => WarehouseRole::RawMaterial->value, ...]) works; $store->warehouseRole returns the raw string (or null if unset).

  • Step 1: Write the failing test

Create tests/Feature/Warehouse/PlatformFoundationTest.php:

php
<?php

namespace Tests\Feature\Warehouse;

use App\Enums\WarehouseRole;
use App\Models\Store\Store;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PlatformFoundationTest extends TestCase
{
    use RefreshDatabase;

    public function test_store_can_be_created_with_a_warehouse_role(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $store = Store::create([
            'type' => 7,
            'name' => 'Raw Materials Warehouse',
            'warehouseRole' => WarehouseRole::RawMaterial->value,
        ]);

        $this->assertSame('raw_material', $store->fresh()->warehouseRole);
    }

    public function test_store_created_without_a_warehouse_role_leaves_it_null(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $store = Store::create(['type' => 7, 'name' => 'Main Warehouse']);

        $this->assertNull($store->fresh()->warehouseRole);
    }
}
  • Step 2: Run test to verify it fails

Run: php artisan test --compact --filter=test_store_can_be_created_with_a_warehouse_role Expected: The create succeeds either way (Mongo is schemaless — an unguarded, undeclared field still saves), but this locks in the contract before the docblock documents it. If it currently passes, that's fine — proceed to Step 3 to make the field a documented, first-class part of the model rather than an undocumented ad hoc key. Confirm the second test also passes as-is (warehouseRole defaults to null with no work needed) — this is the baseline this task formalizes.

  • Step 3: Add warehouseRole to the Store docblock

In app/Models/Store/Store.php, add one line to the @property block (after @property int|null $type, before @property int|null $customID):

php
 * @property int|null $type
 * @property string|null $warehouseRole
 * @property int|null $customID

No $casts entry — this codebase's App\Enums\* classes (e.g. AssetCondition, used in app/Models/Asset/Inspection.php) are applied manually via ::from()/::tryFrom() in requests and business logic, never as native Eloquent enum casts; warehouseRole follows that established pattern, so it stays a plain string field like name/location.

  • Step 4: Run tests to verify they pass

Run: php artisan test --compact tests/Feature/Warehouse/PlatformFoundationTest.php Expected: PASS (2 tests).

  • Step 5: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Models/Store/Store.php tests/Feature/Warehouse/PlatformFoundationTest.php
git commit -m "Warehouse: document Store.warehouseRole classification field"

Task 3: WarehouseTask model

Files:

  • Create: app/Models/Warehouse/WarehouseTask.php
  • Test: tests/Feature/Warehouse/PlatformFoundationTest.php (append to existing file)

Interfaces:

  • Consumes: App\Models\System\Status (existing, for the status() relation — same pattern as every other warehouse model, §2.1).

  • Produces: App\Models\Warehouse\WarehouseTask — collection warehouse_tasks; fields type, refType, refID, fromLocationID, toLocationID, productID, qty, assignedUserID, statusID, sequence, startedAt, completedAt, standardSeconds; relation status(): BelongsTo<Status, $this>.

  • Step 1: Write the failing test

Append to tests/Feature/Warehouse/PlatformFoundationTest.php:

php
    public function test_warehouse_task_can_be_created_and_scoped_to_the_acting_users_team(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $task = \App\Models\Warehouse\WarehouseTask::create([
            'type' => 'putaway',
            'refType' => 'Grn',
            'refID' => 'grn-123',
            'toLocationID' => 'bin-456',
            'productID' => 'prod-789',
            'qty' => 12.5,
            'statusID' => 7,
            'sequence' => 1,
            'standardSeconds' => 90,
        ]);

        $fresh = $task->fresh();
        $this->assertSame('putaway', $fresh->type);
        $this->assertSame('grn-123', $fresh->refID);
        $this->assertSame(12.5, $fresh->qty);
        $this->assertSame(7, $fresh->statusID);
        $this->assertSame($user->id, $fresh->cuid);
    }

    public function test_warehouse_task_soft_deletes(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $task = \App\Models\Warehouse\WarehouseTask::create(['type' => 'pick', 'statusID' => 7]);
        $task->delete();

        $this->assertSoftDeleted($task);
    }
  • Step 2: Run test to verify it fails

Run: php artisan test --compact --filter=test_warehouse_task_can_be_created Expected: FAIL — Class "App\Models\Warehouse\WarehouseTask" not found.

  • Step 3: Write the model
php
<?php

namespace App\Models\Warehouse;

use App\Concerns\BelongsToOrganization;
use App\Models\System\Status;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use MongoDB\Laravel\Eloquent\Model;
use OwenIt\Auditing\Contracts\Auditable;

/**
 * One generalized work-execution queue for put-away, picking,
 * replenishment, counting, and packing tasks — see
 * `.ai/modules/warehouse.md` §11.1 Group 1, design principle 3 ("one task
 * queue, not five"). `refType`/`refID` point loosely at the document this
 * task serves (a `Grn`, a `Wave`, etc.) — a plain field pair, not a formal
 * relation, matching how this module already links loosely-coupled
 * cross-entity references elsewhere (e.g. `GatePass.customerID`).
 *
 * @property string $id
 * @property int $oid
 * @property string $type
 * @property string|null $refType
 * @property string|null $refID
 * @property string|null $fromLocationID
 * @property string|null $toLocationID
 * @property string|null $productID
 * @property float|null $qty
 * @property int|null $assignedUserID
 * @property int|null $statusID
 * @property int|null $sequence
 * @property Carbon|null $startedAt
 * @property Carbon|null $completedAt
 * @property int|null $standardSeconds
 * @property int $cuid
 * @property int $uuid
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property Carbon|null $deleted_at
 * @property-read Status|null $status
 */
class WarehouseTask extends Model implements Auditable
{
    use BelongsToOrganization;
    use \OwenIt\Auditing\Auditable;
    use SoftDeletes;

    protected $connection = 'mongodb';

    protected $table = 'warehouse_tasks';

    protected $guarded = ['oid', 'cuid', 'uuid'];

    protected $casts = [
        'oid' => 'integer',
        'qty' => 'float',
        'assignedUserID' => 'integer',
        'statusID' => 'integer',
        'sequence' => 'integer',
        'startedAt' => 'datetime',
        'completedAt' => 'datetime',
        'standardSeconds' => 'integer',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
        'deleted_at' => 'datetime',
    ];

    protected $with = ['status'];

    /**
     * @return BelongsTo<Status, $this>
     */
    public function status(): BelongsTo
    {
        return $this->belongsTo(Status::class, 'statusID', 'id')->withDefault();
    }
}
  • Step 4: Run tests to verify they pass

Run: php artisan test --compact tests/Feature/Warehouse/PlatformFoundationTest.php Expected: PASS (4 tests so far).

  • Step 5: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Models/Warehouse/WarehouseTask.php tests/Feature/Warehouse/PlatformFoundationTest.php
git commit -m "Warehouse: add WarehouseTask model (warehouse_tasks collection)"

Task 4: WarehouseEvent model (append-only)

Files:

  • Create: app/Models/Warehouse/WarehouseEvent.php
  • Test: tests/Feature/Warehouse/PlatformFoundationTest.php (append)

Interfaces:

  • Produces: App\Models\Warehouse\WarehouseEvent — collection warehouse_events; fields eventType, entityType, entityID, storeID, userID, payload (array cast), occurredAt; update() and delete() both throw RuntimeException.

  • Step 1: Write the failing test

Append to tests/Feature/Warehouse/PlatformFoundationTest.php:

php
    public function test_warehouse_event_can_be_created_with_a_payload(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $event = \App\Models\Warehouse\WarehouseEvent::create([
            'eventType' => 'grn.received',
            'entityType' => 'Grn',
            'entityID' => 'grn-123',
            'storeID' => 'store-456',
            'userID' => $user->id,
            'payload' => ['qty' => 10, 'productID' => 'prod-789'],
            'occurredAt' => now(),
        ]);

        $fresh = $event->fresh();
        $this->assertSame('grn.received', $fresh->eventType);
        $this->assertSame(['qty' => 10, 'productID' => 'prod-789'], $fresh->payload);
    }

    public function test_warehouse_event_cannot_be_updated(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $event = \App\Models\Warehouse\WarehouseEvent::create([
            'eventType' => 'gin.issued',
            'entityType' => 'Gin',
            'entityID' => 'gin-1',
            'occurredAt' => now(),
        ]);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('WarehouseEvent is append-only and cannot be updated.');

        $event->update(['eventType' => 'gin.voided']);
    }

    public function test_warehouse_event_cannot_be_deleted(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $event = \App\Models\Warehouse\WarehouseEvent::create([
            'eventType' => 'gin.issued',
            'entityType' => 'Gin',
            'entityID' => 'gin-1',
            'occurredAt' => now(),
        ]);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('WarehouseEvent is append-only and cannot be deleted.');

        $event->delete();
    }
  • Step 2: Run test to verify it fails

Run: php artisan test --compact --filter=test_warehouse_event_can_be_created_with_a_payload Expected: FAIL — Class "App\Models\Warehouse\WarehouseEvent" not found.

  • Step 3: Write the model
php
<?php

namespace App\Models\Warehouse;

use App\Concerns\BelongsToOrganization;
use Illuminate\Support\Carbon;
use MongoDB\Laravel\Eloquent\Model;
use OwenIt\Auditing\Contracts\Auditable;
use RuntimeException;

/**
 * Append-only event log — the backbone every Phase 1+ feature (analytics,
 * webhooks, notifications) reads from instead of inventing its own
 * tracking. See `.ai/modules/warehouse.md` §11.1 Group 1, design
 * principle 4. Deliberately has no `SoftDeletes`, and both `update()` and
 * `delete()` are blocked below — "append-only" is an enforced guarantee
 * here, not just a naming convention.
 *
 * @property string $id
 * @property int $oid
 * @property string $eventType
 * @property string $entityType
 * @property string $entityID
 * @property string|null $storeID
 * @property int|null $userID
 * @property array<string, mixed>|null $payload
 * @property Carbon $occurredAt
 * @property int $cuid
 * @property int $uuid
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 */
class WarehouseEvent extends Model implements Auditable
{
    use BelongsToOrganization;
    use \OwenIt\Auditing\Auditable;

    protected $connection = 'mongodb';

    protected $table = 'warehouse_events';

    protected $guarded = ['oid', 'cuid', 'uuid'];

    protected $casts = [
        'oid' => 'integer',
        'userID' => 'integer',
        'payload' => 'array',
        'occurredAt' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
    ];

    protected static function booted(): void
    {
        static::updating(function (): void {
            throw new RuntimeException('WarehouseEvent is append-only and cannot be updated.');
        });

        static::deleting(function (): void {
            throw new RuntimeException('WarehouseEvent is append-only and cannot be deleted.');
        });
    }
}
  • Step 4: Run tests to verify they pass

Run: php artisan test --compact tests/Feature/Warehouse/PlatformFoundationTest.php Expected: PASS (7 tests so far).

  • Step 5: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Models/Warehouse/WarehouseEvent.php tests/Feature/Warehouse/PlatformFoundationTest.php
git commit -m "Warehouse: add append-only WarehouseEvent model (warehouse_events collection)"

Task 5: WarehouseAlertRule model

Files:

  • Create: app/Models/Warehouse/WarehouseAlertRule.php
  • Test: tests/Feature/Warehouse/PlatformFoundationTest.php (append)

Interfaces:

  • Produces: App\Models\Warehouse\WarehouseAlertRule — collection warehouse_alert_rules; fields type, entityScope, thresholds (array cast), recipients (array cast), active (bool, default not forced — nullable until Phase 1 wiring sets it).

  • Step 1: Write the failing test

Append to tests/Feature/Warehouse/PlatformFoundationTest.php:

php
    public function test_warehouse_alert_rule_can_be_created_with_thresholds_and_recipients(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $rule = \App\Models\Warehouse\WarehouseAlertRule::create([
            'type' => 'low_stock',
            'entityScope' => 'product',
            'thresholds' => ['minQty' => 10],
            'recipients' => [$user->id],
            'active' => true,
        ]);

        $fresh = $rule->fresh();
        $this->assertSame(['minQty' => 10], $fresh->thresholds);
        $this->assertSame([$user->id], $fresh->recipients);
        $this->assertTrue($fresh->active);
    }
  • Step 2: Run test to verify it fails

Run: php artisan test --compact --filter=test_warehouse_alert_rule_can_be_created Expected: FAIL — Class "App\Models\Warehouse\WarehouseAlertRule" not found.

  • Step 3: Write the model
php
<?php

namespace App\Models\Warehouse;

use App\Concerns\BelongsToOrganization;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use MongoDB\Laravel\Eloquent\Model;
use OwenIt\Auditing\Contracts\Auditable;

/**
 * Configuration for a single alert condition (e.g. low-stock, near-expiry,
 * damaged-stock) — see `.ai/modules/warehouse.md` §11.1 Group 1.
 * `WarehouseNotification` rows are generated from these rules once Phase 1
 * wires alert evaluation to the `WarehouseEvent` stream; this model is
 * configuration-only.
 *
 * @property string $id
 * @property int $oid
 * @property string $type
 * @property string|null $entityScope
 * @property array<string, mixed>|null $thresholds
 * @property array<int, int>|null $recipients
 * @property bool|null $active
 * @property int $cuid
 * @property int $uuid
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property Carbon|null $deleted_at
 */
class WarehouseAlertRule extends Model implements Auditable
{
    use BelongsToOrganization;
    use \OwenIt\Auditing\Auditable;
    use SoftDeletes;

    protected $connection = 'mongodb';

    protected $table = 'warehouse_alert_rules';

    protected $guarded = ['oid', 'cuid', 'uuid'];

    protected $casts = [
        'oid' => 'integer',
        'thresholds' => 'array',
        'recipients' => 'array',
        'active' => 'boolean',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
        'deleted_at' => 'datetime',
    ];
}
  • Step 4: Run tests to verify they pass

Run: php artisan test --compact tests/Feature/Warehouse/PlatformFoundationTest.php Expected: PASS (8 tests so far).

  • Step 5: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Models/Warehouse/WarehouseAlertRule.php tests/Feature/Warehouse/PlatformFoundationTest.php
git commit -m "Warehouse: add WarehouseAlertRule model (warehouse_alert_rules collection)"

Task 6: WarehouseNotification model

Files:

  • Create: app/Models/Warehouse/WarehouseNotification.php
  • Test: tests/Feature/Warehouse/PlatformFoundationTest.php (append)

Interfaces:

  • Produces: App\Models\Warehouse\WarehouseNotification — collection warehouse_notifications; fields type, severity, entityType, entityID, message, recipientUserIDs (array cast), channel, readAt.

  • Step 1: Write the failing test

Append to tests/Feature/Warehouse/PlatformFoundationTest.php:

php
    public function test_warehouse_notification_can_be_created_and_marked_read(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $notification = \App\Models\Warehouse\WarehouseNotification::create([
            'type' => 'low_stock',
            'severity' => 'warning',
            'entityType' => 'Product',
            'entityID' => 'prod-789',
            'message' => 'Product prod-789 is below its reorder point.',
            'recipientUserIDs' => [$user->id],
            'channel' => 'in_app',
        ]);

        $this->assertNull($notification->fresh()->readAt);

        $notification->update(['readAt' => now()]);

        $this->assertNotNull($notification->fresh()->readAt);
    }
  • Step 2: Run test to verify it fails

Run: php artisan test --compact --filter=test_warehouse_notification_can_be_created Expected: FAIL — Class "App\Models\Warehouse\WarehouseNotification" not found.

  • Step 3: Write the model
php
<?php

namespace App\Models\Warehouse;

use App\Concerns\BelongsToOrganization;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use MongoDB\Laravel\Eloquent\Model;
use OwenIt\Auditing\Contracts\Auditable;

/**
 * A single generated notification (low-stock, near-expiry, damaged-stock,
 * or another exception), typically produced by evaluating a
 * `WarehouseAlertRule` against the `WarehouseEvent` stream — see
 * `.ai/modules/warehouse.md` §11.1 Group 1. `readAt` being null means
 * unread; there is no separate boolean flag to keep in sync.
 *
 * @property string $id
 * @property int $oid
 * @property string $type
 * @property string $severity
 * @property string|null $entityType
 * @property string|null $entityID
 * @property string $message
 * @property array<int, int>|null $recipientUserIDs
 * @property string|null $channel
 * @property Carbon|null $readAt
 * @property int $cuid
 * @property int $uuid
 * @property Carbon|null $created_at
 * @property Carbon|null $updated_at
 * @property Carbon|null $deleted_at
 */
class WarehouseNotification extends Model implements Auditable
{
    use BelongsToOrganization;
    use \OwenIt\Auditing\Auditable;
    use SoftDeletes;

    protected $connection = 'mongodb';

    protected $table = 'warehouse_notifications';

    protected $guarded = ['oid', 'cuid', 'uuid'];

    protected $casts = [
        'oid' => 'integer',
        'recipientUserIDs' => 'array',
        'readAt' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
        'deleted_at' => 'datetime',
    ];
}
  • Step 4: Run tests to verify they pass

Run: php artisan test --compact tests/Feature/Warehouse/PlatformFoundationTest.php Expected: PASS (9 tests so far).

  • Step 5: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add app/Models/Warehouse/WarehouseNotification.php tests/Feature/Warehouse/PlatformFoundationTest.php
git commit -m "Warehouse: add WarehouseNotification model (warehouse_notifications collection)"

Task 7: Seed Phase 1 feature-flag keys

Files:

  • Modify: database/seeders/ModulesSeeder.php:115 (insert after the id => 1012 row)
  • Test: tests/Feature/Warehouse/PlatformFoundationTest.php (append)

Interfaces:

  • Consumes: App\Models\System\ModuleSub (existing, table modules_subs, $guarded = []).

  • Produces: 13 new ModuleSub rows, ids 1013–1025, moduleID => 10, gating each Phase 1 track (§12.2) as its own Team.modulesAllowed / modules.warehouse.{key} permission-scoped feature flag. No Page/Action rows — matching the existing, already-safe precedent of submodule 1012 (material-requests), which has zero seeded Page rows today.

  • Step 1: Write the failing test

Append to tests/Feature/Warehouse/PlatformFoundationTest.php:

php
    public function test_modules_seeder_registers_the_phase_one_feature_flag_submodules(): void
    {
        $this->seed(\Database\Seeders\ModulesSeeder::class);

        $expected = [
            1013 => 'mobile-scanning',
            1014 => 'inventory-dashboard',
            1015 => 'directed-putaway',
            1016 => 'cycle-count-scheduling',
            1017 => 'reorder-replenishment',
            1018 => 'rbac',
            1019 => 'notifications',
            1020 => 'incoming-qc-gate',
            1021 => 'consignment-ownership',
            1022 => 'pos-integration',
            1023 => 'valuation-prep',
            1024 => 'gl-posting',
            1025 => 'post-production-qc',
        ];

        foreach ($expected as $id => $key) {
            $subModule = \App\Models\System\ModuleSub::withoutGlobalScopes()->find($id);
            $this->assertNotNull($subModule, "ModuleSub id {$id} was not seeded.");
            $this->assertSame(10, $subModule->moduleID);
            $this->assertSame($key, $subModule->key);
        }
    }
  • Step 2: Run test to verify it fails

Run: php artisan test --compact --filter=test_modules_seeder_registers_the_phase_one_feature_flag_submodules Expected: FAIL — assertion ModuleSub id 1013 was not seeded. (null).

  • Step 3: Add the 13 rows to the seeder

In database/seeders/ModulesSeeder.php, insert immediately after the id => 1012 line (before the blank line that precedes the moduleID => 11 rows):

php
            ['id' => 1012, 'moduleID' => 10, 'key' => 'material-requests', 'name' => 'Material requests', 'order' => 4200],
            ['id' => 1013, 'moduleID' => 10, 'key' => 'mobile-scanning', 'name' => 'Mobile / RF Scanning', 'order' => 10100],
            ['id' => 1014, 'moduleID' => 10, 'key' => 'inventory-dashboard', 'name' => 'Inventory Dashboard', 'order' => 10200],
            ['id' => 1015, 'moduleID' => 10, 'key' => 'directed-putaway', 'name' => 'Directed Put-away & Pick', 'order' => 10300],
            ['id' => 1016, 'moduleID' => 10, 'key' => 'cycle-count-scheduling', 'name' => 'Cycle-Count Scheduling', 'order' => 10400],
            ['id' => 1017, 'moduleID' => 10, 'key' => 'reorder-replenishment', 'name' => 'Reorder-Point Replenishment', 'order' => 10500],
            ['id' => 1018, 'moduleID' => 10, 'key' => 'rbac', 'name' => 'Role-Based Access Control', 'order' => 10600],
            ['id' => 1019, 'moduleID' => 10, 'key' => 'notifications', 'name' => 'Notifications & Alerts', 'order' => 10700],
            ['id' => 1020, 'moduleID' => 10, 'key' => 'incoming-qc-gate', 'name' => 'Incoming QC / Inspection-Lot Gate', 'order' => 10800],
            ['id' => 1021, 'moduleID' => 10, 'key' => 'consignment-ownership', 'name' => 'Consignment / Non-Owned Stock', 'order' => 10900],
            ['id' => 1022, 'moduleID' => 10, 'key' => 'pos-integration', 'name' => 'POS Integration', 'order' => 11000],
            ['id' => 1023, 'moduleID' => 10, 'key' => 'valuation-prep', 'name' => 'Inventory Valuation (Prep)', 'order' => 11100],
            ['id' => 1024, 'moduleID' => 10, 'key' => 'gl-posting', 'name' => 'Physical-Inventory GL Posting', 'order' => 11200],
            ['id' => 1025, 'moduleID' => 10, 'key' => 'post-production-qc', 'name' => 'Post-Production QC Hold', 'order' => 11300],
  • Step 4: Run tests to verify they pass

Run: php artisan test --compact tests/Feature/Warehouse/PlatformFoundationTest.php Expected: PASS (10 tests total).

  • Step 5: Format and commit
bash
vendor/bin/pint --dirty --format agent
git add database/seeders/ModulesSeeder.php tests/Feature/Warehouse/PlatformFoundationTest.php
git commit -m "Warehouse: seed Phase 1 feature-flag submodules (ids 1013-1025)"

Task 8: Full regression check (exit criteria)

Files: none — verification only.

Interfaces: none — this task consumes everything built in Tasks 1–7 and confirms it hasn't broken anything that existed before this plan.

  • Step 1: Run the full existing warehouse suite

Run: php artisan test --compact tests/Feature/Warehouse Expected: PASS — all pre-existing tests (StoreTest, GrnGinTest, TransferTest, GatePassWeighSlipPickListTest, AuditTest, StockMovementTest, InventoryTest) plus the new PlatformFoundationTest all green, per .ai/modules/warehouse.md §12.2 exit criteria: "a tenant with every new flag OFF is byte-identical to today's production behavior."

  • Step 2: Run the full application suite

Run: php artisan test --compact Expected: PASS — confirms nothing outside the Warehouse module (e.g. Inventory module tests that also touch Store/Stock) regressed.

  • Step 3: Final format pass
bash
vendor/bin/pint --format agent

Expected: no remaining style violations across the files this plan touched.

  • Step 4: Commit if the format pass changed anything
bash
git status
# If vendor/bin/pint modified any file:
git add -u
git commit -m "Warehouse: pint formatting pass for Phase 0 platform foundation"

Self-Review

Spec coverage: §11.1 Group 1's four collections → Tasks 3–6. Store.warehouseRole (§10.1, §11.2) → Task 2, backed by the enum in Task 1. Feature-flag keys "for every capability below" (§12.1) → scoped deliberately to the 13 Phase 1 tracks only (Task 7) rather than all 32 new Tier 1–4 items, since seeding flags for phases that haven't been planned yet would be speculative, unused schema — each later phase's own plan should seed its own flags when it's actually being built (YAGNI, and consistent with "recommend/build incrementally" throughout §12). Exit criteria (§12.2) → Task 8. Permission-key wiring/enforcement is explicitly not in this plan — per §12.2 track 6, that's Phase 1's "RBAC rollout," which needs the keys this plan seeds but ships the actual policy classes separately.

Placeholder scan: every step has real, complete code; no TBD/TODO; no "similar to Task N" references.

Type consistency: WarehouseTask.status() returns BelongsTo<Status, $this> matching the exact signature used by Store::status() and PickList::status(). Field names used in tests match field names declared in each model's $casts/docblock exactly (qty, thresholds, recipients, recipientUserIDs, readAt, occurredAt, payload).