Skip to content

Commit fa4b151

Browse files
committed
feat(inventory): Phase 147 — Inventory Shipment Tracking
Adds Shipment and ShipmentItem models with full CRUD and status transitions (dispatch → in-transit, deliver → delivered, return, cancel). Shipment numbers are auto-generated with SHO-/SHI- prefix based on type. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a20b873 commit fa4b151

13 files changed

Lines changed: 891 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Modules\Inventory\Models\Shipment;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class ShipmentController
12+
{
13+
public function index(): Response
14+
{
15+
$shipments = Shipment::with('warehouse')
16+
->orderByDesc('created_at')
17+
->paginate(20);
18+
19+
return Inertia::render('Inventory/Shipments/Index', compact('shipments'));
20+
}
21+
22+
public function create(): Response
23+
{
24+
return Inertia::render('Inventory/Shipments/Create');
25+
}
26+
27+
public function store(Request $request): RedirectResponse
28+
{
29+
$data = $request->validate([
30+
'type' => 'required|string|in:inbound,outbound',
31+
'carrier' => 'nullable|string|max:255',
32+
'tracking_number' => 'nullable|string|max:255',
33+
'service_level' => 'nullable|string|in:standard,express,overnight',
34+
'origin_address' => 'nullable|string',
35+
'destination_address' => 'nullable|string',
36+
'ship_date' => 'nullable|date',
37+
'estimated_delivery' => 'nullable|date',
38+
'weight_kg' => 'nullable|numeric|min:0',
39+
'freight_cost' => 'nullable|numeric|min:0',
40+
'notes' => 'nullable|string',
41+
'warehouse_id' => 'nullable|exists:warehouses,id',
42+
]);
43+
44+
$data['tenant_id'] = app('tenant')->id;
45+
$data['created_by'] = auth()->id();
46+
47+
Shipment::create($data);
48+
49+
return redirect()->route('inventory.shipments.index');
50+
}
51+
52+
public function show(Shipment $shipment): Response
53+
{
54+
$shipment->load('items.product', 'warehouse', 'creator');
55+
return Inertia::render('Inventory/Shipments/Show', compact('shipment'));
56+
}
57+
58+
public function edit(Shipment $shipment): Response
59+
{
60+
return Inertia::render('Inventory/Shipments/Edit', compact('shipment'));
61+
}
62+
63+
public function update(Request $request, Shipment $shipment): RedirectResponse
64+
{
65+
$data = $request->validate([
66+
'carrier' => 'nullable|string|max:255',
67+
'tracking_number' => 'nullable|string|max:255',
68+
'service_level' => 'nullable|string|in:standard,express,overnight',
69+
'origin_address' => 'nullable|string',
70+
'destination_address' => 'nullable|string',
71+
'ship_date' => 'nullable|date',
72+
'estimated_delivery' => 'nullable|date',
73+
'weight_kg' => 'nullable|numeric|min:0',
74+
'freight_cost' => 'nullable|numeric|min:0',
75+
'notes' => 'nullable|string',
76+
'warehouse_id' => 'nullable|exists:warehouses,id',
77+
]);
78+
79+
$shipment->update($data);
80+
81+
return redirect()->route('inventory.shipments.index');
82+
}
83+
84+
public function destroy(Shipment $shipment): RedirectResponse
85+
{
86+
$shipment->delete();
87+
return redirect()->route('inventory.shipments.index');
88+
}
89+
90+
public function dispatch(Shipment $shipment): RedirectResponse
91+
{
92+
$shipment->dispatch();
93+
return redirect()->route('inventory.shipments.index');
94+
}
95+
96+
public function deliver(Shipment $shipment): RedirectResponse
97+
{
98+
$shipment->deliver();
99+
return redirect()->route('inventory.shipments.index');
100+
}
101+
102+
public function returnShipment(Shipment $shipment): RedirectResponse
103+
{
104+
$shipment->returnShipment();
105+
return redirect()->route('inventory.shipments.index');
106+
}
107+
108+
public function cancel(Shipment $shipment): RedirectResponse
109+
{
110+
$shipment->cancel();
111+
return redirect()->route('inventory.shipments.index');
112+
}
113+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Casts\Attribute;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\SoftDeletes;
12+
13+
class Shipment extends Model
14+
{
15+
use BelongsToTenant, SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'shipment_number',
20+
'type',
21+
'status',
22+
'carrier',
23+
'tracking_number',
24+
'service_level',
25+
'origin_address',
26+
'destination_address',
27+
'ship_date',
28+
'estimated_delivery',
29+
'actual_delivery',
30+
'weight_kg',
31+
'freight_cost',
32+
'notes',
33+
'warehouse_id',
34+
'created_by',
35+
];
36+
37+
protected $casts = [
38+
'ship_date' => 'date',
39+
'estimated_delivery' => 'date',
40+
'actual_delivery' => 'date',
41+
'weight_kg' => 'float',
42+
'freight_cost' => 'float',
43+
];
44+
45+
protected $attributes = [
46+
'type' => 'outbound',
47+
'status' => 'pending',
48+
];
49+
50+
public function items(): HasMany
51+
{
52+
return $this->hasMany(ShipmentItem::class);
53+
}
54+
55+
public function warehouse(): BelongsTo
56+
{
57+
return $this->belongsTo(Warehouse::class);
58+
}
59+
60+
public function creator(): BelongsTo
61+
{
62+
return $this->belongsTo(User::class, 'created_by');
63+
}
64+
65+
public function dispatch(): void
66+
{
67+
if ($this->shipment_number === null) {
68+
$this->shipment_number = $this->generateShipmentNumber();
69+
}
70+
$this->status = 'in-transit';
71+
$this->ship_date = $this->ship_date ?? now()->toDateString();
72+
$this->save();
73+
}
74+
75+
public function deliver(): void
76+
{
77+
$this->status = 'delivered';
78+
$this->actual_delivery = now()->toDateString();
79+
$this->save();
80+
}
81+
82+
public function returnShipment(): void
83+
{
84+
$this->status = 'returned';
85+
$this->save();
86+
}
87+
88+
public function cancel(): void
89+
{
90+
$this->status = 'cancelled';
91+
$this->save();
92+
}
93+
94+
public function generateShipmentNumber(): string
95+
{
96+
$prefix = $this->type === 'inbound' ? 'SHI' : 'SHO';
97+
return $prefix . '-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
98+
}
99+
100+
protected function isPending(): Attribute
101+
{
102+
return Attribute::make(get: fn () => $this->status === 'pending');
103+
}
104+
105+
protected function isInTransit(): Attribute
106+
{
107+
return Attribute::make(get: fn () => $this->status === 'in-transit');
108+
}
109+
110+
protected function isDelivered(): Attribute
111+
{
112+
return Attribute::make(get: fn () => $this->status === 'delivered');
113+
}
114+
115+
protected function totalItems(): Attribute
116+
{
117+
return Attribute::make(get: fn () => $this->items()->count());
118+
}
119+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class ShipmentItem extends Model
9+
{
10+
protected $fillable = [
11+
'shipment_id',
12+
'product_id',
13+
'description',
14+
'quantity',
15+
'weight_kg',
16+
'sku',
17+
];
18+
19+
protected $casts = [
20+
'quantity' => 'float',
21+
'weight_kg' => 'float',
22+
];
23+
24+
public function shipment(): BelongsTo
25+
{
26+
return $this->belongsTo(Shipment::class);
27+
}
28+
29+
public function product(): BelongsTo
30+
{
31+
return $this->belongsTo(Product::class);
32+
}
33+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\Shipment;
7+
8+
class ShipmentPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, Shipment $shipment): bool
16+
{
17+
return $user->can('inventory.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('inventory.create');
23+
}
24+
25+
public function update(User $user, Shipment $shipment): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function dispatch(User $user, Shipment $shipment): bool
31+
{
32+
return $user->can('inventory.create');
33+
}
34+
35+
public function deliver(User $user, Shipment $shipment): bool
36+
{
37+
return $user->can('inventory.create');
38+
}
39+
40+
public function cancel(User $user, Shipment $shipment): bool
41+
{
42+
return $user->can('inventory.delete');
43+
}
44+
45+
public function delete(User $user, Shipment $shipment): bool
46+
{
47+
return $user->can('inventory.delete');
48+
}
49+
}

erp/app/Modules/Inventory/Providers/InventoryServiceProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@
8383
use App\Modules\Inventory\Policies\GoodsReceiptPolicy;
8484
use App\Modules\Inventory\Models\StockReservation;
8585
use App\Modules\Inventory\Policies\StockReservationPolicy;
86+
use App\Modules\Inventory\Models\Shipment;
87+
use App\Modules\Inventory\Models\ShipmentItem;
88+
use App\Modules\Inventory\Policies\ShipmentPolicy;
8689
use Illuminate\Support\Facades\Gate;
8790
use Illuminate\Support\ServiceProvider;
8891

@@ -145,5 +148,7 @@ public function boot(): void
145148
Gate::policy(PurchaseRequest::class, PurchaseRequestPolicy::class);
146149
Gate::policy(GoodsReceipt::class, GoodsReceiptPolicy::class);
147150
Gate::policy(GoodsReceiptItem::class, GoodsReceiptPolicy::class);
151+
Gate::policy(Shipment::class, ShipmentPolicy::class);
152+
Gate::policy(ShipmentItem::class, ShipmentPolicy::class);
148153
}
149154
}

erp/app/Modules/Inventory/routes/inventory.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,13 @@
309309
Route::post('goods-receipts/{goods_receipt}/reject', [GoodsReceiptController::class, 'reject'])->name('goods-receipts.reject');
310310
Route::resource('goods-receipts', GoodsReceiptController::class);
311311
});
312+
313+
// Shipments
314+
use App\Modules\Inventory\Http\Controllers\ShipmentController;
315+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
316+
Route::post('shipments/{shipment}/dispatch', [ShipmentController::class, 'dispatch'])->name('shipments.dispatch');
317+
Route::post('shipments/{shipment}/deliver', [ShipmentController::class, 'deliver'])->name('shipments.deliver');
318+
Route::post('shipments/{shipment}/return', [ShipmentController::class, 'returnShipment'])->name('shipments.return');
319+
Route::post('shipments/{shipment}/cancel', [ShipmentController::class, 'cancel'])->name('shipments.cancel');
320+
Route::resource('shipments', ShipmentController::class);
321+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('shipments', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->string('shipment_number')->nullable();
15+
$table->string('type')->default('outbound'); // inbound|outbound
16+
$table->string('status')->default('pending'); // pending|in-transit|delivered|returned|cancelled
17+
$table->string('carrier')->nullable();
18+
$table->string('tracking_number')->nullable();
19+
$table->string('service_level')->nullable(); // standard|express|overnight
20+
$table->text('origin_address')->nullable();
21+
$table->text('destination_address')->nullable();
22+
$table->date('ship_date')->nullable();
23+
$table->date('estimated_delivery')->nullable();
24+
$table->date('actual_delivery')->nullable();
25+
$table->decimal('weight_kg', 10, 3)->nullable();
26+
$table->decimal('freight_cost', 15, 2)->nullable();
27+
$table->text('notes')->nullable();
28+
$table->foreignId('warehouse_id')->nullable()->constrained('warehouses')->nullOnDelete();
29+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
30+
$table->timestamps();
31+
$table->softDeletes();
32+
});
33+
}
34+
35+
public function down(): void
36+
{
37+
Schema::dropIfExists('shipments');
38+
}
39+
};

0 commit comments

Comments
 (0)