# PipelineEngine

> Provides pipelines and stage management. Models use `HasPipeline` trait.
> All pipelines are seeded from feature pack JSON — no admin UI in v1 (Phase 3.3).

---

## Migrations

### `pipelines`
```php
Schema::create('pipelines', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique();
    $table->string('name');
    $table->string('pipeline_type');        // 'lead' | 'deal'
    $table->boolean('is_default')->default(false);
    $table->boolean('is_system')->default(false);
    $table->string('feature_pack_slug')->nullable();
    $table->string('seeder_version')->nullable();
    $table->timestamps();
});
```

### `pipeline_stages`
```php
Schema::create('pipeline_stages', function (Blueprint $table) {
    $table->id();
    $table->foreignId('pipeline_id')->constrained('pipelines')->cascadeOnDelete();
    $table->string('slug');
    $table->string('name');
    $table->string('color')->default('#6366f1');
    $table->integer('sort_order')->default(0);
    $table->boolean('is_won')->default(false);
    $table->boolean('is_lost')->default(false);
    $table->timestamps();
    $table->unique(['pipeline_id', 'slug']);
});
```

### `pipeline_stage_transitions`
```php
Schema::create('pipeline_stage_transitions', function (Blueprint $table) {
    $table->id();
    $table->morphs('entity');               // entity_type + entity_id
    $table->foreignId('from_stage_id')->nullable()->constrained('pipeline_stages');
    $table->foreignId('to_stage_id')->constrained('pipeline_stages');
    $table->string('reason')->nullable();
    $table->foreignId('changed_by')->nullable()->constrained('users');
    $table->timestamp('transitioned_at')->useCurrent();
    $table->timestamps();
    $table->index(['entity_type', 'entity_id']);
});
```

---

## Trait: `HasPipeline`

```php
// CoreApp/app/Traits/HasPipeline.php
namespace CoreApp\Traits;

use CoreApp\Models\PipelineEngine\{Pipeline, PipelineStage, PipelineStageTransition};

trait HasPipeline
{
    public function pipeline(): BelongsTo
    {
        return $this->belongsTo(Pipeline::class);
    }

    public function currentStage(): BelongsTo
    {
        return $this->belongsTo(PipelineStage::class, 'stage_id');
    }

    public function moveToStage(PipelineStage $stage, ?string $reason = null): void
    {
        // Cross-pipeline guard — prevents crafted requests moving entity to a foreign pipeline's stage
        if ($stage->pipeline_id !== $this->pipeline_id) {
            throw new \InvalidArgumentException(
                "Stage {$stage->id} belongs to pipeline {$stage->pipeline_id}, not {$this->pipeline_id}."
            );
        }

        PipelineStageTransition::create([
            'entity_type'   => static::class,
            'entity_id'     => $this->id,
            'from_stage_id' => $this->stage_id,
            'to_stage_id'   => $stage->id,
            'reason'        => $reason,
            'changed_by'    => auth()->id(),
        ]);

        $fromStageId = $this->stage_id;
        $this->updateQuietly(['stage_id' => $stage->id]); // updateQuietly avoids double-firing deal.updated

        // Explicit stage_changed event — gives WorkflowEngine old/new context
        // Fired AFTER the update so $entity->stage_id already reflects the new stage
        app(\CoreApp\Services\WorkflowEngine\WorkflowDispatcher::class)->dispatch(
            static::workflowEntityType() . '.stage_changed',
            $this,
            ['from_stage_id' => $fromStageId, 'to_stage_id' => $stage->id]
        );
    }

    /** Delegates to PipelineService — business logic does not belong in a trait */
    public static function defaultPipelineForType(string $type): Pipeline
    {
        return app(\CoreApp\Services\PipelineEngine\PipelineService::class)->defaultForType($type);
    }
}
```

---

## Exception: `PipelineNotConfiguredException`

```php
// CoreApp/app/Exceptions/PipelineNotConfiguredException.php
namespace CoreApp\Exceptions;

class PipelineNotConfiguredException extends \RuntimeException {}
```

Thrown when `Lead::create()` or `Deal::create()` is called before `setup:crm` has seeded a default pipeline. Controllers should catch this and return a user-friendly error.

---

## Service: `PipelineService`

```php
// CoreApp/app/Services/PipelineEngine/PipelineService.php
class PipelineService
{
    /** Returns stages for Kanban board (Deal/Board.tsx) */
    public function stagesForBoard(string $pipelineType): Collection
    {
        $pipeline = Pipeline::where('pipeline_type', $pipelineType)
            ->where('is_default', true)
            ->with('stages')
            ->first();

        return $pipeline?->stages->sortBy('sort_order') ?? collect();
    }

    /** Read-only JSON — used by CrmApp/Pipeline endpoint (Phase 2) */
    public function allForType(string $type): Collection
    {
        return Pipeline::where('pipeline_type', $type)
            ->with('stages')
            ->get();
    }

    /** Used by Lead/Deal service on create — throws if no default pipeline seeded */
    public function defaultForType(string $type): Pipeline
    {
        return Pipeline::where('pipeline_type', $type)
            ->where('is_default', true)
            ->firstOr(fn () => throw new \CoreApp\Exceptions\PipelineNotConfiguredException(
                "No default {$type} pipeline found. Run setup:crm first."
            ));
    }
}
```

---

## Key Rules

| Rule | Detail |
|------|--------|
| Default pipeline lookup | `Pipeline::where('is_default',true)->firstOrFail()` — never hardcode ID |
| Stage color | Always use the `color` column — seeded from pack JSON |
| Won/Lost flags | Use `is_won`/`is_lost` on stage, not string matching on stage name |
| Transition logging | Always call `moveToStage()` — never `$entity->update(['stage_id'=>...])` directly |
| Cross-pipeline guard | `moveToStage()` throws `InvalidArgumentException` if `stage.pipeline_id !== entity.pipeline_id` |
