Skip to content

Commit f4dc210

Browse files
committed
feat(inventory): Phase 98 — Sales Order Management with fulfillment workflow
Implements full SO lifecycle (draft→confirmed→shipped→delivered/cancelled), ALTER TABLE migrations for existing sales_orders/items tables, SalesOrder and SalesOrderItem models, SalesOrderPolicy, SalesOrderController with confirm/ship/deliver/cancel actions, 3 React/Inertia pages, TypeScript types, sidebar entry, and 10 Pest feature tests (1006→1016 total).
1 parent 9c603e5 commit f4dc210

15 files changed

Lines changed: 808 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\Customer;
7+
use App\Modules\Inventory\Models\Product;
8+
use App\Modules\Inventory\Models\SalesOrder;
9+
use App\Modules\Inventory\Models\SalesOrderItem;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class SalesOrderController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$this->authorize('viewAny', SalesOrder::class);
20+
21+
$orders = SalesOrder::with('customer')
22+
->when($request->status, fn ($q) => $q->where('status', $request->status))
23+
->when($request->customer_id, fn ($q) => $q->where('customer_id', $request->customer_id))
24+
->latest()
25+
->paginate(20)
26+
->withQueryString();
27+
28+
return Inertia::render('Inventory/SalesOrders/Index', [
29+
'orders' => $orders,
30+
'filters' => $request->only(['status', 'customer_id']),
31+
]);
32+
}
33+
34+
public function create(): Response
35+
{
36+
$this->authorize('create', SalesOrder::class);
37+
38+
return Inertia::render('Inventory/SalesOrders/Create', [
39+
'customers' => Customer::orderBy('name')->get(['id', 'name']),
40+
'products' => Product::where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']),
41+
]);
42+
}
43+
44+
public function store(Request $request): RedirectResponse
45+
{
46+
$this->authorize('create', SalesOrder::class);
47+
48+
$validated = $request->validate([
49+
'order_date' => 'required|date',
50+
'expected_date' => 'nullable|date',
51+
'currency' => 'nullable|string|max:3',
52+
'notes' => 'nullable|string',
53+
'customer_id' => 'nullable|exists:contacts,id',
54+
'items' => 'required|array|min:1',
55+
'items.*.description' => 'required|string|max:255',
56+
'items.*.quantity' => 'required|numeric|min:0.01',
57+
'items.*.unit_price' => 'required|numeric|min:0',
58+
'items.*.product_id' => 'nullable|exists:products,id',
59+
]);
60+
61+
$so = SalesOrder::create([
62+
'tenant_id' => auth()->user()->tenant_id,
63+
'so_number' => SalesOrder::generateSoNumber(),
64+
'customer_id' => $validated['customer_id'] ?? null,
65+
'order_date' => $validated['order_date'],
66+
'expected_date' => $validated['expected_date'] ?? null,
67+
'currency' => $validated['currency'] ?? 'USD',
68+
'notes' => $validated['notes'] ?? null,
69+
'created_by' => auth()->id(),
70+
'subtotal' => 0,
71+
'tax' => 0,
72+
'total' => 0,
73+
]);
74+
75+
foreach ($validated['items'] as $item) {
76+
SalesOrderItem::create([
77+
'tenant_id' => auth()->user()->tenant_id,
78+
'sales_order_id' => $so->id,
79+
'product_id' => $item['product_id'] ?? null,
80+
'description' => $item['description'],
81+
'quantity' => $item['quantity'],
82+
'unit_price' => $item['unit_price'],
83+
'shipped_qty' => 0,
84+
]);
85+
}
86+
87+
$so->recalculateTotals();
88+
89+
return redirect()->route('inventory.sales-orders.show', $so);
90+
}
91+
92+
public function show(SalesOrder $salesOrder): Response
93+
{
94+
$this->authorize('view', $salesOrder);
95+
$salesOrder->load(['customer', 'items.product', 'createdBy']);
96+
97+
return Inertia::render('Inventory/SalesOrders/Show', [
98+
'order' => $salesOrder,
99+
]);
100+
}
101+
102+
public function confirm(SalesOrder $salesOrder): RedirectResponse
103+
{
104+
$this->authorize('update', $salesOrder);
105+
$salesOrder->confirm();
106+
107+
return back()->with('success', 'Sales order confirmed.');
108+
}
109+
110+
public function ship(SalesOrder $salesOrder): RedirectResponse
111+
{
112+
$this->authorize('update', $salesOrder);
113+
$salesOrder->ship();
114+
115+
return back()->with('success', 'Sales order shipped.');
116+
}
117+
118+
public function deliver(SalesOrder $salesOrder): RedirectResponse
119+
{
120+
$this->authorize('update', $salesOrder);
121+
$salesOrder->deliver();
122+
123+
return back()->with('success', 'Sales order delivered.');
124+
}
125+
126+
public function cancel(SalesOrder $salesOrder): RedirectResponse
127+
{
128+
$this->authorize('update', $salesOrder);
129+
$salesOrder->cancel();
130+
131+
return back()->with('success', 'Sales order cancelled.');
132+
}
133+
134+
public function destroy(SalesOrder $salesOrder): RedirectResponse
135+
{
136+
$this->authorize('delete', $salesOrder);
137+
$salesOrder->delete();
138+
139+
return redirect()->route('inventory.sales-orders.index');
140+
}
141+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
9+
class Customer extends Model
10+
{
11+
use BelongsToTenant, SoftDeletes;
12+
13+
protected $table = 'contacts';
14+
15+
protected $fillable = [
16+
'tenant_id', 'name', 'email', 'phone', 'address', 'notes', 'is_active',
17+
];
18+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class SalesOrder extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $table = 'sales_orders';
17+
18+
protected $fillable = [
19+
'tenant_id', 'so_number', 'customer_id', 'status',
20+
'order_date', 'expected_date', 'subtotal', 'tax', 'total',
21+
'currency', 'notes', 'created_by',
22+
'confirmed_at', 'shipped_at', 'delivered_at',
23+
];
24+
25+
protected $casts = [
26+
'order_date' => 'date',
27+
'expected_date' => 'date',
28+
'subtotal' => 'float',
29+
'tax' => 'float',
30+
'total' => 'float',
31+
'confirmed_at' => 'datetime',
32+
'shipped_at' => 'datetime',
33+
'delivered_at' => 'datetime',
34+
];
35+
36+
public function customer(): BelongsTo
37+
{
38+
return $this->belongsTo(Customer::class);
39+
}
40+
41+
public function items(): HasMany
42+
{
43+
return $this->hasMany(SalesOrderItem::class);
44+
}
45+
46+
public function createdBy(): BelongsTo
47+
{
48+
return $this->belongsTo(User::class, 'created_by');
49+
}
50+
51+
public static function generateSoNumber(): string
52+
{
53+
return 'SO-' . strtoupper(uniqid());
54+
}
55+
56+
public function confirm(): void
57+
{
58+
$this->status = 'confirmed';
59+
$this->confirmed_at = now();
60+
$this->save();
61+
}
62+
63+
public function ship(): void
64+
{
65+
$this->status = 'shipped';
66+
$this->shipped_at = now();
67+
$this->save();
68+
}
69+
70+
public function deliver(): void
71+
{
72+
$this->status = 'delivered';
73+
$this->delivered_at = now();
74+
$this->save();
75+
}
76+
77+
public function cancel(): void
78+
{
79+
$this->status = 'cancelled';
80+
$this->save();
81+
}
82+
83+
public function recalculateTotals(): void
84+
{
85+
$subtotal = $this->items()->get()->sum(fn ($i) => $i->quantity * $i->unit_price);
86+
$this->subtotal = $subtotal;
87+
$this->total = $subtotal + $this->tax;
88+
$this->save();
89+
}
90+
91+
public function getIsOpenAttribute(): bool
92+
{
93+
return in_array($this->status, ['draft', 'confirmed']);
94+
}
95+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class SalesOrderItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'sales_order_id', 'product_id', 'description',
15+
'quantity', 'unit_price', 'shipped_qty',
16+
];
17+
18+
protected $casts = [
19+
'quantity' => 'float',
20+
'unit_price' => 'float',
21+
'shipped_qty' => 'float',
22+
];
23+
24+
public function salesOrder(): BelongsTo
25+
{
26+
return $this->belongsTo(SalesOrder::class);
27+
}
28+
29+
public function product(): BelongsTo
30+
{
31+
return $this->belongsTo(Product::class);
32+
}
33+
34+
public function getLineTotalAttribute(): float
35+
{
36+
return (float) $this->quantity * (float) $this->unit_price;
37+
}
38+
}
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\Policies;
4+
5+
use App\Models\User;
6+
7+
class SalesOrderPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('inventory.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('inventory.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('inventory.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('inventory.create');
27+
}
28+
29+
public function delete(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('inventory.delete');
32+
}
33+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
use App\Modules\Inventory\Models\SerialNumber;
5151
use App\Modules\Inventory\Policies\LotSerialPolicy;
5252
use App\Modules\Inventory\Policies\PurchaseOrderPolicy;
53+
use App\Modules\Inventory\Models\SalesOrder;
54+
use App\Modules\Inventory\Models\SalesOrderItem;
55+
use App\Modules\Inventory\Policies\SalesOrderPolicy;
5356
use App\Modules\Inventory\Policies\SupplierReviewPolicy;
5457
use Illuminate\Support\Facades\Gate;
5558
use Illuminate\Support\ServiceProvider;
@@ -95,5 +98,7 @@ public function boot(): void
9598
Gate::policy(SerialNumber::class, LotSerialPolicy::class);
9699
Gate::policy(PurchaseOrder::class, PurchaseOrderPolicy::class);
97100
Gate::policy(PurchaseOrderItem::class, PurchaseOrderPolicy::class);
101+
Gate::policy(SalesOrder::class, SalesOrderPolicy::class);
102+
Gate::policy(SalesOrderItem::class, SalesOrderPolicy::class);
98103
}
99104
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,13 @@
195195
Route::resource('serial-numbers', SerialNumberController::class)->only(['index', 'store', 'show']);
196196
});
197197

198+
199+
// Sales Order Management — custom actions BEFORE resource
200+
use App\Modules\Inventory\Http\Controllers\SalesOrderController;
201+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
202+
Route::post('sales-orders/{salesOrder}/confirm', [SalesOrderController::class, 'confirm'])->name('sales-orders.confirm');
203+
Route::post('sales-orders/{salesOrder}/ship', [SalesOrderController::class, 'ship'])->name('sales-orders.ship');
204+
Route::post('sales-orders/{salesOrder}/deliver', [SalesOrderController::class, 'deliver'])->name('sales-orders.deliver');
205+
Route::post('sales-orders/{salesOrder}/cancel', [SalesOrderController::class, 'cancel'])->name('sales-orders.cancel');
206+
Route::resource('sales-orders', SalesOrderController::class)->only(['index', 'create', 'store', 'show', 'destroy']);
207+
});

0 commit comments

Comments
 (0)