# Purchase Pipeline 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:** Replace the disconnected purchase screens with an 8-stage pipeline tracker that shows every purchase like a package delivery — stage, actor, timestamp, and the action needed next. **Architecture:** Add a `stage` enum column to `purchase_requests`, add 3 new tables (`purchase_signatures`, `rfq_invitations`, `supplier_quotes` + items), and wire up 5 new controllers plus 2 services. A `PurchaseStageService` is the single chokepoint for advancing stages. A public `/rfq/{token}` route (outside auth middleware) serves the supplier quote portal. **Tech Stack:** Laravel 12 / PHP 8.2 / SQLite / Tailwind CSS v3 JIT / Alpine.js v3 / HTML5 Canvas (GM signature) / Laravel Mail (RFQ emails) / `wa.me` deep links (WhatsApp) --- ## File Map ### New migrations - `database/migrations/2026_05_18_200001_add_stage_to_purchase_requests_table.php` - `database/migrations/2026_05_18_200002_add_type_to_grn_items_table.php` - `database/migrations/2026_05_18_200003_create_purchase_signatures_table.php` - `database/migrations/2026_05_18_200004_create_rfq_invitations_table.php` - `database/migrations/2026_05_18_200005_create_supplier_quotes_table.php` - `database/migrations/2026_05_18_200006_create_supplier_quote_items_table.php` ### New / modified models - `app/Models/PurchaseSignature.php` (new) - `app/Models/RfqInvitation.php` (new) - `app/Models/SupplierQuote.php` (new) - `app/Models/SupplierQuoteItem.php` (new) - `app/Models/PurchaseRequest.php` (add `stage`, relationships) - `app/Models/GrnItem.php` (add `type`) ### New services - `app/Services/PurchaseStageService.php` - `app/Services/RfqInvitationService.php` ### New controllers - `app/Http/Controllers/Purchase/PurchasePipelineController.php` - `app/Http/Controllers/Purchase/PurchaseSignatureController.php` - `app/Http/Controllers/Purchase/RfqController.php` - `app/Http/Controllers/Purchase/SupplierQuoteController.php` - `app/Http/Controllers/Purchase/RfqPortalController.php` ### New mail - `app/Mail/RfqInvitationMail.php` - `resources/views/mail/rfq-invitation.blade.php` ### New views - `resources/views/purchase/pipeline/index.blade.php` - `resources/views/purchase/pipeline/_timeline.blade.php` (partial) - `resources/views/purchase/signature/show.blade.php` - `resources/views/purchase/rfq/show.blade.php` - `resources/views/purchase/quotes/index.blade.php` - `resources/views/purchase/quotes/compare.blade.php` - `resources/views/rfq/show.blade.php` (public portal) - `resources/views/rfq/submitted.blade.php` (thank-you page) - `resources/views/rfq/expired.blade.php` ### Modified files - `routes/web.php` (new routes) - `resources/views/layouts/app.blade.php` (sidebar link) --- ## Task 1: Schema — add `stage` column to `purchase_requests` **Files:** - Create: `database/migrations/2026_05_18_200001_add_stage_to_purchase_requests_table.php` - Modify: `app/Models/PurchaseRequest.php` - [ ] **Step 1: Write the migration** ```php enum('stage', [ 'draft', 'gm_approval', 'rfq', 'quoting', 'comparison', 'lpo', 'receiving', 'payment', 'complete', ])->default('draft')->after('status'); }); } public function down(): void { Schema::table('purchase_requests', function (Blueprint $table) { $table->dropColumn('stage'); }); } }; ``` - [ ] **Step 2: Update the PurchaseRequest model** Add `stage` to `$fillable` and the relationship stubs that later tasks will fill: ```php protected $fillable = [ 'request_number', 'date', 'project_name', 'department', 'requested_by_name', 'required_date_text', 'location', 'remarks', 'status', 'verified_by_name', 'requested_by', 'approved_by', 'approved_at', 'stage', ]; public function signature() { return $this->hasOne(\App\Models\PurchaseSignature::class); } public function rfqInvitations() { return $this->hasMany(\App\Models\RfqInvitation::class); } public function supplierQuotes() { return $this->hasMany(\App\Models\SupplierQuote::class); } public function awardedQuote() { return $this->hasOne(\App\Models\SupplierQuote::class)->where('is_awarded', true); } ``` - [ ] **Step 3: Run the migration** ``` php artisan migrate ``` Expected: `Migrated: 2026_05_18_200001_add_stage_to_purchase_requests_table` - [ ] **Step 4: Commit** ```bash git add database/migrations/2026_05_18_200001_add_stage_to_purchase_requests_table.php app/Models/PurchaseRequest.php git commit -m "feat: add stage column to purchase_requests" ``` --- ## Task 2: Schema — add `type` column to `grn_items` **Files:** - Create: `database/migrations/2026_05_18_200002_add_type_to_grn_items_table.php` - Modify: `app/Models/GrnItem.php` - [ ] **Step 1: Write the migration** ```php enum('type', ['inventory', 'consumable'])->default('inventory')->after('unit_cost'); }); } public function down(): void { Schema::table('grn_items', function (Blueprint $table) { $table->dropColumn('type'); }); } }; ``` - [ ] **Step 2: Update GrnItem model** Add `type` to `$fillable`. Read the current file first, then edit: ```php // In GrnItem.php, add 'type' to the $fillable array: protected $fillable = [ 'goods_receipt_note_id', 'purchase_order_item_id', 'item_id', 'quantity_received', 'unit_cost', 'type', ]; ``` - [ ] **Step 3: Run the migration** ``` php artisan migrate ``` Expected: `Migrated: 2026_05_18_200002_add_type_to_grn_items_table` - [ ] **Step 4: Commit** ```bash git add database/migrations/2026_05_18_200002_add_type_to_grn_items_table.php app/Models/GrnItem.php git commit -m "feat: add type (inventory/consumable) to grn_items" ``` --- ## Task 3: Schema — new tables (`purchase_signatures`, `rfq_invitations`, `supplier_quotes`, `supplier_quote_items`) **Files:** - Create: `database/migrations/2026_05_18_200003_create_purchase_signatures_table.php` - Create: `database/migrations/2026_05_18_200004_create_rfq_invitations_table.php` - Create: `database/migrations/2026_05_18_200005_create_supplier_quotes_table.php` - Create: `database/migrations/2026_05_18_200006_create_supplier_quote_items_table.php` - [ ] **Step 1: `purchase_signatures` migration** ```php id(); $table->foreignId('purchase_request_id')->constrained()->onDelete('cascade'); $table->foreignId('signed_by')->constrained('users')->onDelete('restrict'); $table->text('signature_image'); // base64 PNG $table->timestamp('signed_at'); $table->string('ip_address')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('purchase_signatures'); } }; ``` - [ ] **Step 2: `rfq_invitations` migration** ```php id(); $table->foreignId('purchase_request_id')->constrained()->onDelete('cascade'); $table->foreignId('supplier_id')->constrained()->onDelete('restrict'); $table->string('token', 64)->unique(); $table->enum('channel', ['email', 'whatsapp', 'both'])->default('both'); $table->timestamp('sent_at')->nullable(); $table->timestamp('opened_at')->nullable(); $table->timestamp('expires_at')->nullable(); $table->enum('status', ['pending', 'sent', 'opened', 'submitted', 'declined'])->default('pending'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('rfq_invitations'); } }; ``` - [ ] **Step 3: `supplier_quotes` migration** ```php id(); $table->foreignId('rfq_invitation_id')->constrained()->onDelete('cascade'); $table->foreignId('purchase_request_id')->constrained()->onDelete('cascade'); $table->foreignId('supplier_id')->constrained()->onDelete('restrict'); $table->timestamp('submitted_at'); $table->integer('lead_time_days')->nullable(); $table->string('payment_terms')->nullable(); $table->text('notes')->nullable(); $table->decimal('total_amount', 12, 3)->default(0); $table->boolean('is_awarded')->default(false); $table->text('award_reason')->nullable(); $table->timestamp('awarded_at')->nullable(); $table->foreignId('awarded_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('supplier_quotes'); } }; ``` - [ ] **Step 4: `supplier_quote_items` migration** ```php id(); $table->foreignId('supplier_quote_id')->constrained()->onDelete('cascade'); $table->text('description'); $table->string('unit', 50)->nullable(); $table->decimal('quantity', 10, 3); $table->decimal('unit_price', 12, 3); $table->decimal('total_price', 12, 3); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('supplier_quote_items'); } }; ``` - [ ] **Step 5: Run migrations** ``` php artisan migrate ``` Expected: 4 new lines, each `Migrated: 2026_05_18_2000{03,04,05,06}_...` - [ ] **Step 6: Commit** ```bash git add database/migrations/2026_05_18_200003_create_purchase_signatures_table.php git add database/migrations/2026_05_18_200004_create_rfq_invitations_table.php git add database/migrations/2026_05_18_200005_create_supplier_quotes_table.php git add database/migrations/2026_05_18_200006_create_supplier_quote_items_table.php git commit -m "feat: add pipeline tables (signatures, rfq_invitations, supplier_quotes)" ``` --- ## Task 4: New models **Files:** - Create: `app/Models/PurchaseSignature.php` - Create: `app/Models/RfqInvitation.php` - Create: `app/Models/SupplierQuote.php` - Create: `app/Models/SupplierQuoteItem.php` - [ ] **Step 1: PurchaseSignature model** ```php 'datetime']; public function purchaseRequest() { return $this->belongsTo(PurchaseRequest::class); } public function signedBy() { return $this->belongsTo(User::class, 'signed_by'); } } ``` - [ ] **Step 2: RfqInvitation model** ```php 'datetime', 'opened_at' => 'datetime', 'expires_at' => 'datetime', ]; public function purchaseRequest() { return $this->belongsTo(PurchaseRequest::class); } public function supplier() { return $this->belongsTo(Supplier::class); } public function quote() { return $this->hasOne(SupplierQuote::class); } public function isExpired(): bool { return $this->expires_at && $this->expires_at->isPast(); } public function isSubmitted(): bool { return $this->status === 'submitted'; } } ``` - [ ] **Step 3: SupplierQuote model** ```php 'datetime', 'awarded_at' => 'datetime', 'is_awarded' => 'boolean', ]; public function rfqInvitation() { return $this->belongsTo(RfqInvitation::class); } public function purchaseRequest() { return $this->belongsTo(PurchaseRequest::class); } public function supplier() { return $this->belongsTo(Supplier::class); } public function items() { return $this->hasMany(SupplierQuoteItem::class); } public function awardedBy() { return $this->belongsTo(User::class, 'awarded_by'); } } ``` - [ ] **Step 4: SupplierQuoteItem model** ```php belongsTo(SupplierQuote::class, 'supplier_quote_id'); } } ``` - [ ] **Step 5: Commit** ```bash git add app/Models/PurchaseSignature.php app/Models/RfqInvitation.php app/Models/SupplierQuote.php app/Models/SupplierQuoteItem.php git commit -m "feat: add pipeline models (PurchaseSignature, RfqInvitation, SupplierQuote, SupplierQuoteItem)" ``` --- ## Task 5: PurchaseStageService — single place that advances stage **Files:** - Create: `app/Services/PurchaseStageService.php` This service is the only place that changes `purchase_requests.stage`. All controllers call it. - [ ] **Step 1: Write the service** ```php stage, self::STAGES); if ($current === false || $current === count(self::STAGES) - 1) { return; } $request->update(['stage' => self::STAGES[$current + 1]]); } public function setStage(PurchaseRequest $request, string $stage): void { abort_unless(in_array($stage, self::STAGES), 422, 'Invalid stage'); $request->update(['stage' => $stage]); } public function stageIndex(string $stage): int { return array_search($stage, self::STAGES) ?: 0; } public function stageLabel(string $stage): string { return match ($stage) { 'draft' => 'Purchase Request', 'gm_approval'=> 'GM Signature', 'rfq' => 'Select Suppliers', 'quoting' => 'Awaiting Quotes', 'comparison' => 'Quote Comparison', 'lpo' => 'LPO Issued', 'receiving' => 'Receiving Materials', 'payment' => 'Payment', 'complete' => 'Complete', default => ucfirst($stage), }; } } ``` - [ ] **Step 2: Commit** ```bash git add app/Services/PurchaseStageService.php git commit -m "feat: add PurchaseStageService for stage advancement" ``` --- ## Task 6: GM Signature controller + canvas UI **Files:** - Create: `app/Http/Controllers/Purchase/PurchaseSignatureController.php` - Create: `resources/views/purchase/signature/show.blade.php` - [ ] **Step 1: Write the controller** ```php validate([ 'signature_image' => ['required', 'string', 'starts_with:data:image/png;base64,'], ]); // Prevent double-signing if ($purchaseRequest->signature) { return back()->with('error', 'This request has already been signed.'); } PurchaseSignature::create([ 'purchase_request_id' => $purchaseRequest->id, 'signed_by' => auth()->id(), 'signature_image' => $validated['signature_image'], 'signed_at' => now(), 'ip_address' => $request->ip(), ]); $stages->advance($purchaseRequest); return redirect()->route('purchase.pipeline.index') ->with('success', 'Signature saved. Request approved and moved to RFQ stage.'); } } ``` - [ ] **Step 2: Write the signature canvas view** ```blade @extends('layouts.app') @section('content')
GM Digital Signature
{{ $request->request_number }}
{{ $request->project_name }}
@if($request->signature)
Signed by {{ $request->signature->signedBy->name }} on {{ $request->signature->signed_at->format('d M Y, H:i') }}
@else

Please draw your signature below using your mouse or touch screen.

@csrf
@endif
@endsection ``` - [ ] **Step 3: Commit** ```bash git add app/Http/Controllers/Purchase/PurchaseSignatureController.php resources/views/purchase/signature/show.blade.php git commit -m "feat: GM signature canvas — controller + view" ``` --- ## Task 7: RfqInvitationService + RfqController **Files:** - Create: `app/Services/RfqInvitationService.php` - Create: `app/Mail/RfqInvitationMail.php` - Create: `resources/views/mail/rfq-invitation.blade.php` - Create: `app/Http/Controllers/Purchase/RfqController.php` - Create: `resources/views/purchase/rfq/show.blade.php` - [ ] **Step 1: Write RfqInvitationService** ```php $purchaseRequest->id, 'supplier_id' => $supplier->id, 'token' => $token, 'channel' => $channel, 'sent_at' => now(), 'expires_at' => now()->addDays(7), 'status' => 'sent', ]); if (in_array($channel, ['email', 'both']) && $supplier->email) { Mail::to($supplier->email)->send(new RfqInvitationMail($invitation)); } return $invitation; } public function whatsappLink(RfqInvitation $invitation): string { $url = route('rfq.show', $invitation->token); $text = "Hello {$invitation->supplier->name},\n\nYou are invited to submit a quote for purchase request {$invitation->purchaseRequest->request_number}.\n\nPlease click the link below to submit your quote:\n{$url}\n\nThis link expires in 7 days and can only be used once."; $phone = preg_replace('/\D/', '', $invitation->supplier->phone ?? ''); return "https://wa.me/{$phone}?text=" . rawurlencode($text); } } ``` - [ ] **Step 2: Write RfqInvitationMail** ```php invitation->purchaseRequest->request_number); } public function content(): Content { return new Content(view: 'mail.rfq-invitation'); } } ``` - [ ] **Step 3: Write the email blade template** ```blade
Quote Request
{{ $invitation->purchaseRequest->request_number }}

Dear {{ $invitation->supplier->name }},

You have been invited to submit a price quotation for a purchase request. Please click the button below to view the required items and submit your quote.

Submit Quote →

This link expires on {{ $invitation->expires_at->format('d M Y') }} and can only be submitted once.

``` - [ ] **Step 4: Write RfqController** ```php orderBy('name')->get(); $invitations = $request->rfqInvitations()->with('supplier', 'quote')->get(); return view('purchase.rfq.show', compact('request', 'suppliers', 'invitations')); } public function store(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages) { $validated = $request->validate([ 'suppliers' => ['required', 'array', 'min:1'], 'suppliers.*.id' => ['required', 'exists:suppliers,id'], 'suppliers.*.channel' => ['required', 'in:email,whatsapp,both'], ]); $alreadyInvited = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray(); foreach ($validated['suppliers'] as $entry) { if (in_array($entry['id'], $alreadyInvited)) { continue; // skip already-invited suppliers } $supplier = Supplier::findOrFail($entry['id']); $service->invite($purchaseRequest, $supplier, $entry['channel']); } $stages->setStage($purchaseRequest, 'quoting'); return redirect()->route('purchase.requests.rfq', $purchaseRequest) ->with('success', 'Invitations sent. Waiting for supplier quotes.'); } } ``` - [ ] **Step 5: Write the RFQ selection view** ```blade @extends('layouts.app') @section('content')
RFQ — Select Suppliers
{{ $request->request_number }}
@if($invitations->count())
Already Invited
@foreach($invitations as $inv)
{{ $inv->supplier->name }}
{{ ucfirst($inv->channel) }} · {{ $inv->sent_at?->format('d M Y') }}
{{ ucfirst($inv->status) }}
@endforeach
@endif
@csrf
Add Suppliers
@foreach($suppliers as $supplier) @php $alreadyInvited = $invitations->where('supplier_id', $supplier->id)->first(); @endphp
@endforeach
@endsection ``` - [ ] **Step 6: Commit** ```bash git add app/Services/RfqInvitationService.php app/Mail/RfqInvitationMail.php resources/views/mail/rfq-invitation.blade.php app/Http/Controllers/Purchase/RfqController.php resources/views/purchase/rfq/show.blade.php git commit -m "feat: RFQ invitation service, mail, controller and view" ``` --- ## Task 8: Public supplier quote portal (RfqPortalController + views) **Files:** - Create: `app/Http/Controllers/Purchase/RfqPortalController.php` - Create: `resources/views/rfq/show.blade.php` - Create: `resources/views/rfq/submitted.blade.php` - Create: `resources/views/rfq/expired.blade.php` - [ ] **Step 1: Write RfqPortalController** ```php with(['purchaseRequest.items', 'supplier']) ->firstOrFail(); return $invitation; } public function show(string $token) { $invitation = $this->resolveInvitation($token); if ($invitation->isSubmitted()) { return view('rfq.submitted', compact('invitation')); } if ($invitation->isExpired()) { return view('rfq.expired', compact('invitation')); } // Mark as opened on first visit if ($invitation->status === 'sent') { $invitation->update(['status' => 'opened', 'opened_at' => now()]); } $purchaseRequest = $invitation->purchaseRequest; $items = $purchaseRequest->items; return view('rfq.show', compact('invitation', 'purchaseRequest', 'items')); } public function submit(Request $request, string $token, PurchaseStageService $stages) { $invitation = $this->resolveInvitation($token); if ($invitation->isSubmitted() || $invitation->isExpired()) { abort(403); } $validated = $request->validate([ 'lead_time_days' => ['nullable', 'integer', 'min:0'], 'payment_terms' => ['nullable', 'string', 'max:200'], 'notes' => ['nullable', 'string', 'max:1000'], 'items' => ['required', 'array'], 'items.*.unit_price' => ['required', 'numeric', 'min:0'], ]); $purchaseItems = $invitation->purchaseRequest->items; $quote = SupplierQuote::create([ 'rfq_invitation_id' => $invitation->id, 'purchase_request_id'=> $invitation->purchase_request_id, 'supplier_id' => $invitation->supplier_id, 'submitted_at' => now(), 'lead_time_days' => $validated['lead_time_days'], 'payment_terms' => $validated['payment_terms'], 'notes' => $validated['notes'], 'total_amount' => 0, ]); $total = 0; foreach ($purchaseItems as $i => $item) { $unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0); $totalPrice = $unitPrice * $item->quantity; $total += $totalPrice; SupplierQuoteItem::create([ 'supplier_quote_id' => $quote->id, 'description' => $item->description, 'unit' => $item->unit ?? '', 'quantity' => $item->quantity, 'unit_price' => $unitPrice, 'total_price' => $totalPrice, ]); } $quote->update(['total_amount' => $total]); $invitation->update(['status' => 'submitted']); return view('rfq.submitted', compact('invitation')); } } ``` - [ ] **Step 2: Public quote form view (`resources/views/rfq/show.blade.php`)** Note: This view uses no auth layout — it is a standalone public page. ```blade Quote Request — {{ $purchaseRequest->request_number }}
Quote Request
{{ $purchaseRequest->request_number }}
{{ $purchaseRequest->project_name }}

Hello {{ $invitation->supplier->name }}, please fill in your prices for the items below and submit your quote. This link can only be submitted once.

@csrf
@foreach($items as $i => $item) @endforeach
# Description Qty Unit Unit Price (BD) Total (BD)
{{ $i + 1 }} {{ $item->description }} {{ $item->quantity }} {{ $item->unit ?? '—' }}
Grand Total: BD 0.000
``` - [ ] **Step 3: Thank-you view (`resources/views/rfq/submitted.blade.php`)** ```blade Quote Submitted
Quote Submitted
Thank you, {{ $invitation->supplier->name }}. Your quote for {{ $invitation->purchaseRequest->request_number }} has been received. You will be contacted if you are selected.
``` - [ ] **Step 4: Expired view (`resources/views/rfq/expired.blade.php`)** ```blade Link Expired
Link Expired
This quote invitation link has expired. Please contact the purchasing team if you still wish to submit a quote.
``` - [ ] **Step 5: Commit** ```bash git add app/Http/Controllers/Purchase/RfqPortalController.php resources/views/rfq/ git commit -m "feat: public supplier quote portal — form, submitted, expired views" ``` --- ## Task 9: SupplierQuoteController — view quotes + compare + award **Files:** - Create: `app/Http/Controllers/Purchase/SupplierQuoteController.php` - Create: `resources/views/purchase/quotes/index.blade.php` - Create: `resources/views/purchase/quotes/compare.blade.php` - [ ] **Step 1: Write SupplierQuoteController** ```php supplierQuotes()->with('supplier', 'items')->get(); return view('purchase.quotes.index', compact('request', 'quotes')); } public function compare(PurchaseRequest $request) { $quotes = $request->supplierQuotes()->with('supplier', 'items')->get(); $items = $request->items; return view('purchase.quotes.compare', compact('request', 'quotes', 'items')); } public function award(Request $request, PurchaseRequest $purchaseRequest, SupplierQuote $quote, PurchaseStageService $stages) { $validated = $request->validate([ 'award_reason' => ['required', 'string', 'min:5'], ]); if ($purchaseRequest->awardedQuote) { return back()->with('error', 'A quote has already been awarded for this request.'); } // Mark all other quotes as not awarded (they remain in the table) $purchaseRequest->supplierQuotes()->where('id', '!=', $quote->id)->update(['is_awarded' => false]); $quote->update([ 'is_awarded' => true, 'award_reason' => $validated['award_reason'], 'awarded_at' => now(), 'awarded_by' => auth()->id(), ]); $stages->setStage($purchaseRequest, 'lpo'); return redirect()->route('purchase.pipeline.index') ->with('success', "Quote from {$quote->supplier->name} awarded. Ready to issue LPO."); } } ``` - [ ] **Step 2: Write quotes index view** ```blade @extends('layouts.app') @section('content')
Supplier Quotes
{{ $request->request_number }}
@if($quotes->count() >= 1) Compare → @endif
@if($quotes->isEmpty())

No quotes received yet.

@else
@foreach($quotes as $quote)
{{ $quote->supplier->name }}
BD {{ number_format($quote->total_amount, 3) }}
Lead time: {{ $quote->lead_time_days ? $quote->lead_time_days.' days' : '—' }} · Terms: {{ $quote->payment_terms ?: '—' }} · Submitted: {{ $quote->submitted_at->format('d M Y') }}
@if($quote->is_awarded)
✓ Awarded
@endif
@endforeach
@endif
@endsection ``` - [ ] **Step 3: Write quote comparison view** ```blade @extends('layouts.app') @section('content')
Quote Comparison
{{ $request->request_number }}
@if($quotes->isEmpty())

No quotes to compare.

@else @foreach($quotes as $quote) @endforeach {{-- Items rows --}} @foreach($items as $i => $reqItem) @php $rowPrices = $quotes->map(fn($q) => optional($q->items->get($i))->unit_price ?? null)->filter(); $minPrice = $rowPrices->min(); @endphp @foreach($quotes as $q) @php $qItem = $q->items->get($i); @endphp @endforeach @endforeach {{-- Totals row --}} @php $minTotal = $quotes->min('total_amount'); @endphp @foreach($quotes as $quote) @endforeach {{-- Lead time --}} @foreach($quotes as $quote) @endforeach {{-- Payment terms --}} @foreach($quotes as $quote) @endforeach {{-- Award buttons --}} @if(!$request->awardedQuote) @foreach($quotes as $quote) @endforeach @else @endif
Item {{ $quote->supplier->name }}
{{ $quote->submitted_at->format('d M') }}
{{ $reqItem->description }}
Qty: {{ $reqItem->quantity }}
@if($qItem)
BD {{ number_format($qItem->unit_price, 3) }}
Total: BD {{ number_format($qItem->total_price, 3) }}
@else @endif
Grand Total BD {{ number_format($quote->total_amount, 3) }}
Lead Time {{ $quote->lead_time_days ? $quote->lead_time_days.' days' : '—' }}
Payment Terms {{ $quote->payment_terms ?: '—' }}
Award to:
✓ Quote awarded to {{ $request->awardedQuote->supplier->name }}
@endif
@endsection ``` - [ ] **Step 4: Commit** ```bash git add app/Http/Controllers/Purchase/SupplierQuoteController.php resources/views/purchase/quotes/ git commit -m "feat: supplier quote controller — index, compare, award views" ``` --- ## Task 10: Pipeline index view with vertical timeline **Files:** - Create: `app/Http/Controllers/Purchase/PurchasePipelineController.php` - Create: `resources/views/purchase/pipeline/index.blade.php` - [ ] **Step 1: Write PurchasePipelineController** ```php orderByRaw("CASE stage WHEN 'draft' THEN 0 WHEN 'gm_approval' THEN 1 WHEN 'rfq' THEN 2 WHEN 'quoting' THEN 3 WHEN 'comparison' THEN 4 WHEN 'lpo' THEN 5 WHEN 'receiving' THEN 6 WHEN 'payment' THEN 7 WHEN 'complete' THEN 8 ELSE 9 END") ->latest()->get(); return view('purchase.pipeline.index', compact('requests', 'stages')); } } ``` - [ ] **Step 2: Write the pipeline index view** ```blade @extends('layouts.app') @section('content')

Purchase Pipeline

Track every purchase from request to payment

+ New Request
@if($requests->isEmpty())
📋
No purchases yet
Create a purchase request to get started.
@endif
@foreach($requests as $pr) @php $stageIdx = $stages->stageIndex($pr->stage); $allStages = \App\Services\PurchaseStageService::STAGES; @endphp
{{ $pr->request_number }}
{{ $pr->project_name }} · {{ $pr->date->format('d M Y') }}
{{ $stages->stageLabel($pr->stage) }}
@foreach($allStages as $i => $stage) @php $done = $i < $stageIdx; $current = $i === $stageIdx; $future = $i > $stageIdx; @endphp
@if($done) @endif
@if($i < count($allStages) - 1)
@endif
{{ $stages->stageLabel($stage) }} {{-- Action button for current stage --}} @if($current) @if($stage === 'gm_approval') Sign → @elseif($stage === 'rfq') Select Suppliers → @elseif($stage === 'quoting') View Quotes ({{ $pr->supplierQuotes->count() }}) → @elseif($stage === 'comparison') Compare & Award → @elseif($stage === 'lpo') Issue LPO → @elseif($stage === 'receiving') Record GRN → @elseif($stage === 'payment') Issue Payment → @endif @endif
{{-- Stage detail text --}} @if($done || $current)
@if($stage === 'draft') Created by {{ $pr->requestedBy->name }} · {{ $pr->created_at->format('d M Y') }} @elseif($stage === 'gm_approval' && $pr->signature) Signed by {{ $pr->signature->signedBy->name }} · {{ $pr->signature->signed_at->format('d M Y') }} @elseif($stage === 'rfq' || $stage === 'quoting') {{ $pr->rfqInvitations->count() }} supplier(s) invited @elseif($stage === 'comparison') {{ $pr->supplierQuotes->count() }} quote(s) received @elseif($stage === 'lpo' && $pr->awardedQuote) Awarded to {{ $pr->awardedQuote->supplier->name }} @endif
@endif
@endforeach
@endforeach
@endsection ``` - [ ] **Step 3: Commit** ```bash git add app/Http/Controllers/Purchase/PurchasePipelineController.php resources/views/purchase/pipeline/ git commit -m "feat: purchase pipeline index — card list with vertical timeline" ``` --- ## Task 11: Route wiring + sidebar navigation link **Files:** - Modify: `routes/web.php` - Modify: `resources/views/layouts/app.blade.php` - [ ] **Step 1: Add all new routes to `routes/web.php`** Add these imports at the top of `web.php` (after existing Purchase imports): ```php use App\Http\Controllers\Purchase\PurchasePipelineController; use App\Http\Controllers\Purchase\PurchaseSignatureController; use App\Http\Controllers\Purchase\RfqController; use App\Http\Controllers\Purchase\SupplierQuoteController; use App\Http\Controllers\Purchase\RfqPortalController; ``` Add the public RFQ route **before** the auth middleware group: ```php // Public — no auth required Route::get('/rfq/{token}', [RfqPortalController::class, 'show'])->name('rfq.show'); Route::post('/rfq/{token}', [RfqPortalController::class, 'submit'])->name('rfq.submit'); ``` Inside the `purchase.` prefix group, add: ```php Route::get('pipeline', [PurchasePipelineController::class, 'index'])->name('pipeline.index'); Route::get('requests/{request}/sign', [PurchaseSignatureController::class, 'show'])->name('requests.sign'); Route::post('requests/{request}/sign', [PurchaseSignatureController::class, 'store'])->name('requests.sign.store'); Route::get('requests/{request}/rfq', [RfqController::class, 'show'])->name('requests.rfq'); Route::post('requests/{request}/rfq', [RfqController::class, 'store'])->name('requests.rfq.store'); Route::get('requests/{request}/quotes', [SupplierQuoteController::class, 'index'])->name('requests.quotes'); Route::get('requests/{request}/compare', [SupplierQuoteController::class, 'compare'])->name('requests.compare'); Route::post('requests/{request}/quotes/{quote}/award', [SupplierQuoteController::class, 'award'])->name('requests.quotes.award'); ``` - [ ] **Step 2: Add sidebar link to the layout** Open `resources/views/layouts/app.blade.php`. Find where the sidebar purchase links are. Add a "Pipeline" link as the first item under Purchase: ```blade Pipeline ``` (Match the exact element tag/class used by the rest of the sidebar navigation links in the file.) - [ ] **Step 3: Verify routes load** ``` php artisan route:list --path=purchase/pipeline php artisan route:list --path=rfq ``` Expected: `purchase.pipeline.index`, `rfq.show`, `rfq.submit` listed. - [ ] **Step 4: Commit** ```bash git add routes/web.php resources/views/layouts/app.blade.php git commit -m "feat: wire pipeline routes and sidebar navigation" ``` --- ## Task 12: Seed existing purchase requests with `draft` stage Existing `purchase_requests` rows will have a NULL `stage` (migration sets default `draft` for new rows but existing NULL rows won't match the pipeline logic). **Files:** - Create: `database/seeders/PurchaseRequestStagePatchSeeder.php` - [ ] **Step 1: Write the one-time patch seeder** ```php where('status', 'approved')->whereNull('stage')->update(['stage' => 'rfq']); DB::table('purchase_requests')->where('status', 'pending')->whereNull('stage')->update(['stage' => 'gm_approval']); DB::table('purchase_requests')->whereNull('stage')->update(['stage' => 'draft']); } } ``` - [ ] **Step 2: Run the seeder** ``` php artisan db:seed --class=PurchaseRequestStagePatchSeeder ``` Expected: No errors; existing rows now have non-null stage values. - [ ] **Step 3: Commit** ```bash git add database/seeders/PurchaseRequestStagePatchSeeder.php git commit -m "feat: patch existing purchase_requests with initial stage values" ``` --- ## Task 13: GRN receiving view — toggle Inventory / Consumable per item **Files:** - Modify: `resources/views/purchase/grns/create.blade.php` This is a targeted edit to the existing GRN creation form. Find the item rows section and add a toggle after the quantity/cost fields for each item. - [ ] **Step 1: Read the existing GRN create view** to understand how item rows are rendered before editing. ``` Read: resources/views/purchase/grns/create.blade.php ``` - [ ] **Step 2: For each item row in the form, add a type toggle** After the existing quantity / unit cost inputs for each GRN item, add: ```blade
``` And a small JS helper (once in the view, not per row): ```javascript function setGrnType(span, val) { const parent = span.closest('label').parentElement; parent.querySelectorAll('.grn-type-btn').forEach(b => { b.style.background = '#fff'; b.style.color = '#64748b'; }); span.style.background = val === 'inventory' ? '#eff6ff' : '#fef3c7'; span.style.color = val === 'inventory' ? '#2563eb' : '#92400e'; } ``` - [ ] **Step 3: Update GoodsReceiptNoteController to accept and store the `type` field** In `GoodsReceiptNoteController::store()`, when creating each `GrnItem`, pass `type` from the request: ```php $item->type = $itemData['type'] ?? 'inventory'; $item->save(); ``` (Read the exact controller code first to insert at the right location.) - [ ] **Step 4: Commit** ```bash git add resources/views/purchase/grns/create.blade.php app/Http/Controllers/Purchase/GoodsReceiptNoteController.php git commit -m "feat: GRN item type toggle — inventory or consumable" ``` --- ## Self-Review Checklist **Spec coverage:** - [x] Stage 1 (Purchase Request) — existing, no new code needed; stage seeded via Task 12 - [x] Stage 2 (GM Signature) — Task 6 - [x] Stage 3 (Select Suppliers + RFQ) — Task 7 - [x] Stage 4 (Supplier Quotes via private link) — Task 8 - [x] Stage 5 (Comparison + Award) — Task 9 - [x] Stage 6 (LPO) — action button links to existing `purchase.orders.create`; no new controller needed - [x] Stage 7 (Receiving with inventory/consumable) — Task 13 - [x] Stage 8 (Payment) — action button links to existing `purchase.payments.create`; no new controller needed - [x] Pipeline index view — Task 10 - [x] Stage advancement via `PurchaseStageService` — Task 5 - [x] Token expiry, one-submit-only — `RfqPortalController::show()` checks both - [x] Awarding is irreversible / locks comparison — `SupplierQuoteController::award()` guard - [x] WhatsApp deep link — `RfqInvitationService::whatsappLink()` - [x] Email send — `RfqInvitationMail` + `rfq-invitation.blade.php` - [x] Public route outside auth — Task 11 adds routes before middleware group - [x] `grn_items.type` field — Task 2 + Task 13 **Known dependency:** Stage 6 (LPO) and Stage 8 (Payment) action buttons link to existing controllers. Those controllers don't advance the pipeline stage automatically. A follow-up task should add `$stages->setStage($pr, 'receiving')` after LPO creation, and `$stages->setStage($pr, 'complete')` after payment creation. This can be done as a polish pass without blocking the core pipeline.