# Group Delivery Note 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:** Allow staff to release multiple consignments at once and print a single Group Delivery Note (GDN) covering all of them.

**Architecture:** Two new tables (`group_delivery_notes`, `group_delivery_note_items`) sit alongside the existing single-consignment `delivery_notes` table without touching it. A new Filament `BulkAction` on the Stock Register drives the release flow. The existing `DocumentController::print()` gains a `group-delivery-note` branch, and `document-print.blade.php` gains a new `$isGDN` section.

**Tech Stack:** Laravel 12 migrations, Eloquent models, Filament v5 `BulkAction`, Blade template, `SequenceService` locked transaction pattern.

---

## File Map

| Action | File |
|---|---|
| Create | `database/migrations/2026_05_14_000001_create_group_delivery_notes_table.php` |
| Create | `database/migrations/2026_05_14_000002_create_group_delivery_note_items_table.php` |
| Create | `app/Models/GroupDeliveryNote.php` |
| Create | `app/Models/GroupDeliveryNoteItem.php` |
| Modify | `app/Services/SequenceService.php` — add `nextGdnNumber()` |
| Modify | `app/Filament/Resources/Warehouse/Tables/StockRegisterTable.php` — add bulk action |
| Modify | `app/Http/Controllers/DocumentController.php` — add GDN print branch |
| Modify | `resources/views/document-print.blade.php` — add `$isGDN` section |
| Modify | `app/Filament/Concerns/HasDocumentActions.php` — update `view_dn` URL |

---

## Task 1: Migrations

**Files:**
- Create: `database/migrations/2026_05_14_000001_create_group_delivery_notes_table.php`
- Create: `database/migrations/2026_05_14_000002_create_group_delivery_note_items_table.php`

- [ ] **Step 1: Create group_delivery_notes migration**

```php
<?php
// database/migrations/2026_05_14_000001_create_group_delivery_notes_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('group_delivery_notes', function (Blueprint $table) {
            $table->id();
            $table->string('gdn_number')->unique();
            $table->string('receipt_number');
            $table->timestamp('issued_at')->nullable();
            $table->string('received_by');
            $table->string('collector_id_number');
            $table->string('collector_phone')->nullable();
            $table->text('notes')->nullable();
            $table->foreignId('created_by')
                ->nullable()
                ->constrained('users')
                ->nullOnDelete();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('group_delivery_notes');
    }
};
```

- [ ] **Step 2: Create group_delivery_note_items migration**

```php
<?php
// database/migrations/2026_05_14_000002_create_group_delivery_note_items_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('group_delivery_note_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('gdn_id')
                ->constrained('group_delivery_notes')
                ->cascadeOnDelete();
            $table->foreignId('consignment_id')
                ->constrained('consignments')
                ->cascadeOnDelete();
            $table->unique('consignment_id'); // one consignment per GDN max
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('group_delivery_note_items');
    }
};
```

- [ ] **Step 3: Run migrations**

```bash
php artisan migrate
```

Expected output: two new tables created, no errors.

- [ ] **Step 4: Commit**

```bash
git add database/migrations/2026_05_14_000001_create_group_delivery_notes_table.php
git add database/migrations/2026_05_14_000002_create_group_delivery_note_items_table.php
git commit -m "Add group_delivery_notes and group_delivery_note_items migrations"
```

---

## Task 2: Models

**Files:**
- Create: `app/Models/GroupDeliveryNote.php`
- Create: `app/Models/GroupDeliveryNoteItem.php`

- [ ] **Step 1: Create GroupDeliveryNote model**

```php
<?php
// app/Models/GroupDeliveryNote.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

class GroupDeliveryNote extends Model
{
    protected $fillable = [
        'gdn_number',
        'receipt_number',
        'issued_at',
        'received_by',
        'collector_id_number',
        'collector_phone',
        'notes',
        'created_by',
    ];

    protected $casts = [
        'issued_at' => 'datetime',
    ];

    public function createdBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }

    public function items(): HasMany
    {
        return $this->hasMany(GroupDeliveryNoteItem::class, 'gdn_id');
    }

    public function consignments(): HasManyThrough
    {
        return $this->hasManyThrough(
            Consignment::class,
            GroupDeliveryNoteItem::class,
            'gdn_id',
            'id',
            'id',
            'consignment_id'
        );
    }
}
```

- [ ] **Step 2: Create GroupDeliveryNoteItem model**

```php
<?php
// app/Models/GroupDeliveryNoteItem.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class GroupDeliveryNoteItem extends Model
{
    protected $fillable = [
        'gdn_id',
        'consignment_id',
    ];

    public function groupDeliveryNote(): BelongsTo
    {
        return $this->belongsTo(GroupDeliveryNote::class, 'gdn_id');
    }

    public function consignment(): BelongsTo
    {
        return $this->belongsTo(Consignment::class);
    }
}
```

- [ ] **Step 3: Commit**

```bash
git add app/Models/GroupDeliveryNote.php app/Models/GroupDeliveryNoteItem.php
git commit -m "Add GroupDeliveryNote and GroupDeliveryNoteItem models"
```

---

## Task 3: SequenceService — nextGdnNumber()

**Files:**
- Modify: `app/Services/SequenceService.php`

- [ ] **Step 1: Add import and method to SequenceService**

Open `app/Services/SequenceService.php`. Add `use App\Models\GroupDeliveryNote;` to the imports at the top, then add this method after `nextDnNumber()`:

```php
use App\Models\GroupDeliveryNote; // add to existing imports block

public static function nextGdnNumber(): string
{
    return DB::transaction(function () {
        $last = GroupDeliveryNote::lockForUpdate()->orderByDesc('id')->value('gdn_number');
        $next = $last
            ? (int) substr($last, strrpos($last, '-') + 1) + 1
            : 1;
        return 'GDN-' . date('Y') . '-' . str_pad($next, 5, '0', STR_PAD_LEFT);
    });
}
```

- [ ] **Step 2: Verify no syntax errors**

```bash
php artisan tinker --execute="echo App\Services\SequenceService::nextGdnNumber();"
```

Expected: `GDN-2026-00001` (or next available number).

- [ ] **Step 3: Commit**

```bash
git add app/Services/SequenceService.php
git commit -m "Add nextGdnNumber() to SequenceService"
```

---

## Task 4: Bulk Release Action on StockRegisterTable

**Files:**
- Modify: `app/Filament/Resources/Warehouse/Tables/StockRegisterTable.php`

The existing file ends `->actions([...])` then `->defaultSort(...)`. We add `->bulkActions([...])` between them.

- [ ] **Step 1: Add imports at the top of StockRegisterTable.php**

Add these to the existing `use` block:

```php
use App\Models\GroupDeliveryNote;
use App\Models\GroupDeliveryNoteItem;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Illuminate\Database\Eloquent\Collection;
```

- [ ] **Step 2: Add bulkActions() call**

Find the line `->defaultSort('customer_name', 'asc');` in `StockRegisterTable::configure()` and insert the bulk actions block before it:

```php
->bulkActions([
    BulkActionGroup::make([
        BulkAction::make('bulk_release')
            ->label('Release Selected')
            ->icon('heroicon-o-check-badge')
            ->color('success')
            ->requiresConfirmation(false)
            ->deselectRecordsAfterCompletion()
            ->visible(fn (): bool =>
                auth()->user()?->hasPermissionTo('release_goods') ?? false
            )
            ->form([
                TextInput::make('receipt_number')
                    ->label('Receipt Number')
                    ->required()
                    ->placeholder('e.g. RCP-2026-00001')
                    ->helperText('Single receipt covering all selected consignments.'),

                TextInput::make('collected_by')
                    ->label('Collector Name')
                    ->required()
                    ->placeholder('Full name of person collecting'),

                TextInput::make('collector_id_number')
                    ->label('ID / Passport Number')
                    ->required()
                    ->placeholder('e.g. 12345678'),

                TextInput::make('collector_phone')
                    ->label('Collector Phone')
                    ->tel()
                    ->nullable()
                    ->placeholder('e.g. 0712345678'),

                Textarea::make('notes')
                    ->label('Notes (optional)')
                    ->rows(2),
            ])
            ->action(function (Collection $records, array $data): void {
                // 1. Verify all selected are ready_for_pickup
                $notReady = $records->filter(fn ($r) => $r->status !== 'ready_for_pickup');
                if ($notReady->isNotEmpty()) {
                    $names = $notReady->pluck('customer_name')->implode(', ');
                    Notification::make()
                        ->title('Cannot release — some items not ready')
                        ->body("These consignments are not Ready for Pickup: {$names}")
                        ->danger()->persistent()->send();
                    return;
                }

                // 2. Verify receipt exists and is linked to at least one selected consignment
                $consignmentIds = $records->pluck('id');
                $payment = Payment::where('receipt_number', $data['receipt_number'])
                    ->where(function ($q) use ($consignmentIds) {
                        $q->whereIn('consignment_id', $consignmentIds)
                          ->orWhereHas('invoice', fn ($inv) =>
                              $inv->whereIn('consignment_id', $consignmentIds)
                                  ->orWhereHas('items', fn ($item) =>
                                      $item->whereIn('consignment_id', $consignmentIds)
                                  )
                          );
                    })
                    ->first();

                if (! $payment) {
                    Notification::make()
                        ->title('Receipt not found')
                        ->body("Receipt {$data['receipt_number']} is not linked to any of the selected consignments.")
                        ->danger()->persistent()->send();
                    return;
                }

                // 3. Atomic bulk status update — race-safe
                $updated = Consignment::whereIn('id', $consignmentIds)
                    ->where('status', 'ready_for_pickup')
                    ->update([
                        'status'       => 'delivered',
                        'collected_by' => $data['collected_by'],
                        'collected_at' => now(),
                    ]);

                if ($updated === 0) {
                    Notification::make()
                        ->title('Already released')
                        ->body('These goods were just released by another staff member.')
                        ->warning()->send();
                    return;
                }

                // 4. Create GDN + items + stock movements + history inside a transaction
                DB::transaction(function () use ($consignmentIds, $data, $payment) {
                    $gdn = GroupDeliveryNote::create([
                        'gdn_number'          => SequenceService::nextGdnNumber(),
                        'receipt_number'      => $data['receipt_number'],
                        'issued_at'           => now(),
                        'received_by'         => $data['collected_by'],
                        'collector_id_number' => $data['collector_id_number'],
                        'collector_phone'     => $data['collector_phone'] ?? null,
                        'notes'               => $data['notes'] ?? null,
                        'created_by'          => auth()->id(),
                    ]);

                    foreach ($consignmentIds as $cId) {
                        GroupDeliveryNoteItem::create([
                            'gdn_id'          => $gdn->id,
                            'consignment_id'  => $cId,
                        ]);

                        StockMovement::create([
                            'consignment_id' => $cId,
                            'type'           => 'OUT',
                            'receipt_number' => $data['receipt_number'],
                            'collected_by'   => $data['collected_by'],
                            'notes'          => "Group release — GDN: {$gdn->gdn_number}",
                            'moved_by'       => auth()->id(),
                            'moved_at'       => now(),
                        ]);

                        ConsignmentStatusHistory::create([
                            'consignment_id' => $cId,
                            'status'         => 'delivered',
                            'note'           => "Group release to: {$data['collected_by']} — GDN: {$gdn->gdn_number}",
                            'changed_by'     => auth()->id(),
                        ]);
                    }

                    // Store GDN id on the session for the redirect below
                    session(['last_gdn_id' => $gdn->id]);
                });

                $count = $consignmentIds->count();
                Notification::make()
                    ->title("{$count} consignment(s) released — GDN created")
                    ->success()->send();
            })
            ->successRedirectUrl(fn (): string =>
                route('documents.print', ['group-delivery-note', session('last_gdn_id')])
            ),
    ]),
])
```

- [ ] **Step 3: Clear caches and verify no syntax errors**

```bash
php artisan optimize:clear
```

Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add app/Filament/Resources/Warehouse/Tables/StockRegisterTable.php
git commit -m "Add bulk release action to Stock Register — creates Group Delivery Note"
```

---

## Task 5: DocumentController — GDN Print Branch

**Files:**
- Modify: `app/Http/Controllers/DocumentController.php`

- [ ] **Step 1: Add import and GDN branch**

Add `use App\Models\GroupDeliveryNote;` to the existing imports. Then in `print()`, add the GDN branch **before** the existing delivery-note fallback:

```php
use App\Models\GroupDeliveryNote; // add to imports

// Inside print() method, add BEFORE the existing delivery-note block:
if ($type === 'group-delivery-note') {
    return $this->printGroupDeliveryNote($id);
}
```

Then add the private method at the end of the class:

```php
private function printGroupDeliveryNote(int $gdnId)
{
    $gdn = GroupDeliveryNote::with([
        'items.consignment.shipment:id,type,reference,vessel_or_flight,origin',
        'createdBy:id,name',
    ])->findOrFail($gdnId);

    $docType = 'group-delivery-note';
    $title   = 'Group Delivery Note ' . $gdn->gdn_number;
    $backUrl = url()->previous(fallback: url('/admin'));

    return view('document-print', compact('gdn', 'docType', 'title', 'backUrl'));
}
```

- [ ] **Step 2: Commit**

```bash
git add app/Http/Controllers/DocumentController.php
git commit -m "Add printGroupDeliveryNote() to DocumentController"
```

---

## Task 6: document-print.blade.php — Group DN Section

**Files:**
- Modify: `resources/views/document-print.blade.php`

- [ ] **Step 1: Add $isGDN to the @php block**

Find the `@php` block near the top of `<body>` (around line 228) that sets `$isInvoice`, `$isDN`, `$isReceipt`. Add:

```php
$isGDN      = $docType === 'group-delivery-note';
```

Also extend `$docNumber` and `$docDate`:

```php
$docNumber = $isInvoice
    ? ($document->invoice_number ?? '—')
    : ($isDN
        ? ($document->dn_number ?? '—')
        : ($isGDN
            ? ($gdn->gdn_number ?? '—')
            : ($payment->receipt_number ?? '—')));

$docDate = $isInvoice
    ? $document->created_at->format('M d, Y')
    : ($isDN
        ? ($document->issued_at?->format('M d, Y') ?? now()->format('M d, Y'))
        : ($isGDN
            ? ($gdn->issued_at?->format('M d, Y') ?? now()->format('M d, Y'))
            : ($payment->created_at->format('M d, Y'))));
```

- [ ] **Step 2: Update toolbar badge**

Find:
```php
{{ $isInvoice ? 'Invoice' : ($isDN ? 'Delivery Note' : 'Receipt') }}
```
Replace with:
```php
{{ $isInvoice ? 'Invoice' : ($isDN ? 'Delivery Note' : ($isGDN ? 'Group Delivery Note' : 'Receipt')) }}
```

Also update the `var delay` JS line:
```javascript
var delay = {{ ($isReceipt) ? 100 : 600 }};
```
(No change needed — non-receipt types already use 600ms.)

- [ ] **Step 3: Add GDN section to the document body**

Find the `@if(false)` comment block that opens the document section (`{{-- Invoices now use document-invoice.blade.php — only DN rendered here --}}`). The current structure is:

```
@if(false)
@elseif($isInvoice) ...
@else (delivery note)
@endif
```

Change it to:

```
@if(false)
@elseif($isInvoice) ...
@elseif($isGDN) ... NEW SECTION ...
@else (delivery note)
@endif
```

The new GDN section (insert between `@elseif($isInvoice)` block and the existing `@else`):

```blade
        {{-- ── GROUP DELIVERY NOTE ───────────────────────────── --}}
        @elseif($isGDN)

        {{-- Confirmed banner --}}
        <div style="background:#f0fdf4;border:2px solid #16a34a;border-radius:10px;
                    padding:14px 20px;margin-bottom:24px;
                    display:flex;align-items:center;justify-content:space-between;gap:16px;">
            <div style="display:flex;align-items:center;gap:10px;">
                <span style="font-size:20px;">&#10003;</span>
                <div>
                    <div style="font-size:13px;font-weight:700;color:#15803d;">
                        GOODS RELEASED — GROUP DELIVERY CONFIRMED
                    </div>
                    <div style="font-size:12px;color:#166534;margin-top:2px;">
                        Receipt: {{ $gdn->receipt_number }}
                    </div>
                </div>
            </div>
            <div style="text-align:right;font-size:12px;color:#166534;">
                <div><strong>Date:</strong> {{ $gdn->issued_at?->format('d M Y, H:i') ?? '—' }}</div>
            </div>
        </div>

        {{-- Items table --}}
        @php
            $gdnConsignments = $gdn->items->map(fn ($item) => $item->consignment)->filter();
            $totalPcs        = $gdnConsignments->sum('pcs');
            $totalKgs        = $gdnConsignments->sum('actual_weight_kgs');
            $totalCbm        = $gdnConsignments->sum('cbm');
        @endphp

        <div class="doc-section">
            <div class="section-heading">Cargo Details</div>
            <table class="cargo-table">
                <thead>
                    <tr>
                        <th>HAWB / CN</th>
                        <th>Description</th>
                        <th>Shipment</th>
                        <th>Type</th>
                        <th class="center">PCS</th>
                        <th class="right">KGS</th>
                        <th class="right">CBM</th>
                        <th>Destination</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach($gdnConsignments as $gc)
                    <tr>
                        <td style="font-weight:700;">
                            {{ $gc->house_waybill_no ?? $gc->container_no ?? '—' }}
                        </td>
                        <td>{{ $gc->description ?? '—' }}</td>
                        <td style="font-size:11px;">
                            {{ $gc->shipment?->vessel_or_flight ?? $gc->shipment?->reference ?? '—' }}
                        </td>
                        <td>
                            <span style="font-size:10px;font-weight:700;text-transform:uppercase;
                                color:{{ $gc->shipment?->type === 'air' ? '#1d4ed8' : '#92400e' }}">
                                {{ strtoupper($gc->shipment?->type ?? '—') }}
                            </span>
                        </td>
                        <td class="center">{{ $gc->pcs }}</td>
                        <td class="right">{{ $gc->actual_weight_kgs > 0 ? number_format($gc->actual_weight_kgs, 2) : '—' }}</td>
                        <td class="right">{{ $gc->cbm > 0 ? number_format($gc->cbm, 3) : '—' }}</td>
                        <td>{{ $gc->destination }}</td>
                    </tr>
                    @endforeach
                </tbody>
                <tfoot>
                    <tr style="font-weight:700;border-top:2px solid #1a1a2e;font-size:12px;">
                        <td colspan="4" style="text-align:right;padding-right:8px;">TOTALS</td>
                        <td class="center">{{ $totalPcs }}</td>
                        <td class="right">{{ $totalKgs > 0 ? number_format($totalKgs, 2) . ' KGS' : '—' }}</td>
                        <td class="right">{{ $totalCbm > 0 ? number_format($totalCbm, 3) . ' CBM' : '—' }}</td>
                        <td></td>
                    </tr>
                </tfoot>
            </table>
        </div>

        @if($gdn->notes)
        <div class="doc-section">
            <p style="font-size:13px;color:#6b7280;font-style:italic;">{{ $gdn->notes }}</p>
        </div>
        @endif

        {{-- Signatures --}}
        <div class="sig-section">
            <div>
                <div class="sig-label">Released By — Cargo Staff</div>
                @if($gdn->createdBy)
                <div style="margin-top:4px;padding:6px 10px;border:1.5px solid #2c2860;border-radius:6px;">
                    <div style="font-size:13px;font-weight:700;color:#2c2860;">
                        {{ $gdn->createdBy->name }}
                    </div>
                    @if($gdn->issued_at)
                    <div style="font-size:11px;color:#6b7280;margin-top:2px;">
                        {{ $gdn->issued_at->format('d M Y, H:i') }}
                    </div>
                    @endif
                </div>
                @else
                <div class="sig-line"></div>
                <div class="sig-sub">Name &amp; Signature</div>
                @endif
            </div>
            <div>
                <div class="sig-label">Received By — Consignee</div>
                <div style="border:1.5px solid #16a34a;border-radius:6px;padding:8px 12px;margin-top:4px;">
                    <div style="font-size:13px;font-weight:700;color:#15803d;">
                        {{ $gdn->received_by }}
                    </div>
                    @if($gdn->collector_id_number)
                    <div style="font-size:11px;color:#166534;margin-top:2px;">
                        ID: {{ $gdn->collector_id_number }}
                    </div>
                    @endif
                    @if($gdn->collector_phone)
                    <div style="font-size:11px;color:#166534;margin-top:2px;">
                        Tel: {{ $gdn->collector_phone }}
                    </div>
                    @endif
                </div>
            </div>
        </div>
```

- [ ] **Step 4: Clear view cache and test**

```bash
php artisan view:clear
```

Navigate to `/admin/warehouse/stock`, select multiple ready-for-pickup rows, submit the form. Confirm redirect to the GDN print page renders without errors.

- [ ] **Step 5: Commit**

```bash
git add resources/views/document-print.blade.php
git commit -m "Add group delivery note section to document-print template"
```

---

## Task 7: HasDocumentActions — Update view_dn for GDN

**Files:**
- Modify: `app/Filament/Concerns/HasDocumentActions.php`

- [ ] **Step 1: Add GroupDeliveryNoteItem import**

Add to the existing `use` imports at the top of the file:

```php
use App\Models\GroupDeliveryNoteItem;
```

- [ ] **Step 2: Update view_dn action**

Find the `view_dn` action (around line 139). Currently:

```php
Action::make('view_dn')
    ->label('View Delivery Note')
    ->icon('heroicon-o-document-check')
    ->color('gray')
    ->visible(fn (Consignment $record): bool =>
        $record->status === 'delivered' &&
        (bool) $record->deliveryNote
    )
    ->url(fn (Consignment $record): string =>
        route('documents.print', ['delivery-note', $record->id])
    )
    ->openUrlInNewTab(),
```

Replace with:

```php
Action::make('view_dn')
    ->label('View Delivery Note')
    ->icon('heroicon-o-document-check')
    ->color('gray')
    ->visible(fn (Consignment $record): bool =>
        $record->status === 'delivered' &&
        ((bool) $record->deliveryNote ||
         GroupDeliveryNoteItem::where('consignment_id', $record->id)->exists())
    )
    ->url(function (Consignment $record): string {
        $gdnItem = GroupDeliveryNoteItem::where('consignment_id', $record->id)->first();
        if ($gdnItem) {
            return route('documents.print', ['group-delivery-note', $gdnItem->gdn_id]);
        }
        return route('documents.print', ['delivery-note', $record->id]);
    })
    ->openUrlInNewTab(),
```

- [ ] **Step 3: Commit**

```bash
git add app/Filament/Concerns/HasDocumentActions.php
git commit -m "Update view_dn to redirect to GDN print for group-released consignments"
```

---

## Task 8: Final Polish + Push

- [ ] **Step 1: Clear all caches**

```bash
php artisan optimize:clear
php artisan view:clear
```

- [ ] **Step 2: Run migrations on production server**

```bash
php artisan migrate --force
```

- [ ] **Step 3: Push**

```bash
git push origin main
```

- [ ] **Step 4: Smoke test on production**

1. Go to Stock Register
2. Ensure two+ consignments have status `ready_for_pickup`
3. Select them via checkboxes → click "Release Selected"
4. Fill form → submit
5. Confirm redirect to GDN print page showing all consignments
6. Go back to Stock Register — rows show `delivered`
7. Click "View Delivery Note" on one of the released rows → confirm it opens the GDN (not 404)

---

## Self-Review Checklist

- [x] Migrations cover all columns in spec
- [x] `consignment_id` unique constraint on `group_delivery_note_items` prevents double-GDN
- [x] `SequenceService::nextGdnNumber()` follows same locked-transaction pattern as `nextDnNumber()`
- [x] Bulk action verifies `ready_for_pickup` status before update
- [x] Bulk action verifies receipt exists
- [x] Atomic WHERE-IN update is race-safe (0 rows = already released)
- [x] `GroupDeliveryNote` is created inside the same `DB::transaction()` as items/movements
- [x] `successRedirectUrl` uses `session('last_gdn_id')` set during the transaction
- [x] `DocumentController::printGroupDeliveryNote()` eager-loads items → consignments → shipments
- [x] `$isGDN` added to @php block; `$docNumber` and `$docDate` extended for GDN
- [x] GDN section inserts as `@elseif($isGDN)` — does not touch existing DN or receipt sections
- [x] `view_dn` visible check extended to include GDN membership
- [x] `view_dn` URL resolver checks GDN first, falls back to solo DN
- [x] `HasDocumentActions` has correct import for `GroupDeliveryNoteItem`
- [x] Existing single-release flow (`StockRegisterTable` release action) is not modified
