Skip to content

Commit 985f057

Browse files
committed
feat(inventory): Phase 95 — Purchase Order Management with receiving workflow
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f177cdd commit 985f057

13 files changed

Lines changed: 657 additions & 591 deletions

File tree

erp/app/Modules/Inventory/Http/Controllers/PurchaseOrderController.php

Lines changed: 129 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33
namespace App\Modules\Inventory\Http\Controllers;
44

55
use App\Http\Controllers\Controller;
6-
use App\Modules\Inventory\Http\Requests\ReceivePurchaseOrderRequest;
7-
use App\Modules\Inventory\Http\Requests\StorePurchaseOrderRequest;
8-
use App\Modules\Inventory\Http\Resources\PurchaseOrderResource;
96
use App\Modules\Inventory\Models\Product;
107
use App\Modules\Inventory\Models\PurchaseOrder;
8+
use App\Modules\Inventory\Models\PurchaseOrderItem;
9+
use App\Modules\Inventory\Models\StockMovement;
1110
use App\Modules\Inventory\Models\Supplier;
12-
use App\Modules\Inventory\Models\Warehouse;
1311
use Illuminate\Http\RedirectResponse;
1412
use Illuminate\Http\Request;
1513
use Inertia\Inertia;
@@ -19,162 +17,187 @@ class PurchaseOrderController extends Controller
1917
{
2018
public function index(Request $request): Response
2119
{
22-
$orders = PurchaseOrder::with(['supplier', 'warehouse'])
23-
->when($request->status, fn ($q) => $q->where('status', $request->status))
20+
$this->authorize('viewAny', PurchaseOrder::class);
21+
22+
$orders = PurchaseOrder::with('supplier')
23+
->when($request->status, fn ($q) => $q->where('status', $request->status))
2424
->when($request->supplier_id, fn ($q) => $q->where('supplier_id', $request->supplier_id))
2525
->latest()
26-
->paginate(25)
26+
->paginate(20)
2727
->withQueryString();
2828

2929
return Inertia::render('Inventory/PurchaseOrders/Index', [
30-
'orders' => PurchaseOrderResource::collection($orders),
31-
'suppliers' => Supplier::where('is_active', true)->orderBy('name')->get(['id', 'name']),
32-
'filters' => $request->only(['status', 'supplier_id']),
33-
'breadcrumbs' => [
34-
['label' => 'Inventory'],
35-
['label' => 'Purchase Orders', 'href' => route('inventory.purchase-orders.index')],
36-
],
30+
'orders' => $orders,
31+
'filters' => $request->only(['status', 'supplier_id']),
3732
]);
3833
}
3934

4035
public function create(): Response
4136
{
37+
$this->authorize('create', PurchaseOrder::class);
38+
4239
return Inertia::render('Inventory/PurchaseOrders/Create', [
43-
'suppliers' => Supplier::where('is_active', true)->orderBy('name')->get(['id', 'name']),
44-
'warehouses' => Warehouse::where('is_active', true)->orderBy('name')->get(['id', 'name']),
45-
'products' => Product::active()->with('uom')->orderBy('name')
46-
->get(['id', 'name', 'sku', 'cost_price', 'uom_id']),
47-
'breadcrumbs' => [
48-
['label' => 'Inventory'],
49-
['label' => 'Purchase Orders', 'href' => route('inventory.purchase-orders.index')],
50-
['label' => 'New Order'],
51-
],
40+
'suppliers' => Supplier::orderBy('name')->get(['id', 'name']),
41+
'products' => Product::where('is_active', true)->orderBy('name')->get(['id', 'name', 'sku']),
5242
]);
5343
}
5444

55-
public function store(StorePurchaseOrderRequest $request): RedirectResponse
45+
public function store(Request $request): RedirectResponse
5646
{
57-
$data = $request->validated();
47+
$this->authorize('create', PurchaseOrder::class);
48+
49+
$validated = $request->validate([
50+
'supplier_id' => 'nullable|exists:suppliers,id',
51+
'order_date' => 'required|date',
52+
'expected_date' => 'nullable|date',
53+
'currency' => 'nullable|string|max:3',
54+
'notes' => 'nullable|string',
55+
'items' => 'required|array|min:1',
56+
'items.*.description' => 'required|string|max:255',
57+
'items.*.quantity' => 'required|numeric|min:0.01',
58+
'items.*.unit_price' => 'required|numeric|min:0',
59+
'items.*.product_id' => 'nullable|exists:products,id',
60+
]);
5861

5962
$po = PurchaseOrder::create([
60-
'tenant_id' => auth()->user()->tenant_id,
61-
'supplier_id' => $data['supplier_id'],
62-
'warehouse_id' => $data['warehouse_id'],
63-
'expected_date' => $data['expected_date'] ?? null,
64-
'notes' => $data['notes'] ?? null,
65-
'created_by' => auth()->id(),
63+
'tenant_id' => auth()->user()->tenant_id,
64+
'po_number' => PurchaseOrder::generatePoNumber(),
65+
'supplier_id' => $validated['supplier_id'] ?? null,
66+
'order_date' => $validated['order_date'],
67+
'expected_date' => $validated['expected_date'] ?? null,
68+
'currency' => $validated['currency'] ?? 'USD',
69+
'notes' => $validated['notes'] ?? null,
70+
'created_by' => auth()->id(),
71+
'subtotal' => 0,
72+
'tax' => 0,
73+
'total' => 0,
6674
]);
6775

68-
foreach ($data['items'] as $item) {
69-
$po->items()->create([
70-
'product_id' => $item['product_id'],
71-
'quantity' => $item['quantity'],
72-
'unit_cost' => $item['unit_cost'],
76+
foreach ($validated['items'] as $item) {
77+
PurchaseOrderItem::create([
78+
'tenant_id' => auth()->user()->tenant_id,
79+
'purchase_order_id' => $po->id,
80+
'product_id' => $item['product_id'] ?? null,
81+
'description' => $item['description'],
82+
'quantity' => $item['quantity'],
83+
'unit_price' => $item['unit_price'],
84+
'received_qty' => 0,
7385
]);
7486
}
7587

76-
return redirect()->route('inventory.purchase-orders.show', $po)
77-
->with('success', 'Purchase order created.');
88+
$po->recalculateTotals();
89+
90+
return redirect()->route('inventory.purchase-orders.show', $po);
7891
}
7992

8093
public function show(PurchaseOrder $purchaseOrder): Response
8194
{
82-
$purchaseOrder->load(['supplier', 'warehouse', 'items.product', 'creator']);
95+
$this->authorize('view', $purchaseOrder);
96+
$purchaseOrder->load(['supplier', 'items.product', 'createdBy']);
8397

8498
return Inertia::render('Inventory/PurchaseOrders/Show', [
85-
'order' => new PurchaseOrderResource($purchaseOrder),
86-
'transitions' => $purchaseOrder->availableTransitions(),
87-
'breadcrumbs' => [
88-
['label' => 'Inventory'],
89-
['label' => 'Purchase Orders', 'href' => route('inventory.purchase-orders.index')],
90-
['label' => "PO #{$purchaseOrder->id}"],
91-
],
99+
'order' => $purchaseOrder,
92100
]);
93101
}
94102

95-
public function receiveForm(PurchaseOrder $purchaseOrder): Response
103+
public function send(PurchaseOrder $purchaseOrder): RedirectResponse
96104
{
97-
if (! $purchaseOrder->canTransitionTo('received')) {
98-
return redirect()->route('inventory.purchase-orders.show', $purchaseOrder)
99-
->withErrors(['status' => 'This purchase order cannot be received in its current status.']);
100-
}
105+
$this->authorize('update', $purchaseOrder);
106+
$purchaseOrder->send();
101107

102-
$purchaseOrder->load(['supplier', 'warehouse', 'items.product']);
108+
return back()->with('success', 'Purchase order sent.');
109+
}
103110

104-
return Inertia::render('Inventory/PurchaseOrders/Receive', [
105-
'order' => new PurchaseOrderResource($purchaseOrder),
106-
'breadcrumbs' => [
107-
['label' => 'Inventory'],
108-
['label' => 'Purchase Orders', 'href' => route('inventory.purchase-orders.index')],
109-
['label' => "PO-" . str_pad($purchaseOrder->id, 4, '0', STR_PAD_LEFT), 'href' => route('inventory.purchase-orders.show', $purchaseOrder)],
110-
['label' => 'Receive Items'],
111-
],
112-
]);
111+
public function cancel(PurchaseOrder $purchaseOrder): RedirectResponse
112+
{
113+
$this->authorize('update', $purchaseOrder);
114+
$purchaseOrder->cancel();
115+
116+
return back()->with('success', 'Purchase order cancelled.');
113117
}
114118

115-
public function transition(Request $request, PurchaseOrder $purchaseOrder): RedirectResponse
119+
public function receive(Request $request, PurchaseOrder $purchaseOrder): RedirectResponse
116120
{
117-
$status = $request->validate(['status' => ['required', 'string']])['status'];
118-
119-
try {
120-
if ($status === 'received') {
121-
$lines = $purchaseOrder->items->map(fn ($i) => [
122-
'id' => $i->id,
123-
'received_quantity' => $i->quantity,
124-
])->all();
125-
$purchaseOrder->receive($lines);
126-
} else {
127-
$purchaseOrder->transitionTo($status);
121+
$this->authorize('update', $purchaseOrder);
122+
123+
// Accept both 'items' and 'lines' key; both 'received_qty' and 'received_quantity'
124+
$lines = $request->input('items') ?? $request->input('lines') ?? [];
125+
126+
foreach ($lines as $data) {
127+
$item = PurchaseOrderItem::find($data['id'] ?? null);
128+
if (!$item || $item->purchase_order_id !== $purchaseOrder->id) {
129+
continue;
130+
}
131+
$qty = (float) ($data['received_qty'] ?? $data['received_quantity'] ?? 0);
132+
$prevQty = (float) $item->received_qty;
133+
$item->received_qty = $qty;
134+
$item->save();
135+
136+
// Create stock movement for the delta if warehouse is set
137+
$delta = $qty - $prevQty;
138+
if ($delta > 0 && $purchaseOrder->warehouse_id && $item->product_id) {
139+
StockMovement::record([
140+
'product_id' => $item->product_id,
141+
'warehouse_id' => $purchaseOrder->warehouse_id,
142+
'type' => 'in',
143+
'quantity' => $delta,
144+
'reference' => $purchaseOrder->po_number ?? ('PO-' . $purchaseOrder->id),
145+
'notes' => 'PO receiving',
146+
]);
128147
}
129-
} catch (\DomainException $e) {
130-
return back()->withErrors(['status' => $e->getMessage()]);
131148
}
132149

133-
return back()->with('success', "Order {$status}.");
134-
}
150+
$allReceived = $purchaseOrder->items()->get()->every(fn ($i) => (float)$i->received_qty >= (float)$i->quantity);
151+
$anyReceived = $purchaseOrder->items()->where('received_qty', '>', 0)->exists();
135152

136-
public function submit(PurchaseOrder $purchaseOrder): RedirectResponse
137-
{
138-
try {
139-
$purchaseOrder->transitionTo('submitted');
140-
} catch (\DomainException $e) {
141-
return back()->withErrors(['status' => $e->getMessage()]);
153+
if ($allReceived) {
154+
$purchaseOrder->markReceived();
155+
} elseif ($anyReceived) {
156+
$purchaseOrder->status = 'partial';
157+
$purchaseOrder->save();
142158
}
143159

144-
return back()->with('success', 'Purchase order submitted.');
160+
return back()->with('success', 'Receiving updated.');
145161
}
146162

147-
public function approve(PurchaseOrder $purchaseOrder): RedirectResponse
163+
public function receiveForm(PurchaseOrder $purchaseOrder): Response
148164
{
149-
try {
150-
$purchaseOrder->transitionTo('approved');
151-
} catch (\DomainException $e) {
152-
return back()->withErrors(['status' => $e->getMessage()]);
153-
}
154-
155-
return back()->with('success', 'Purchase order approved.');
165+
$this->authorize('view', $purchaseOrder);
166+
$purchaseOrder->load(['supplier', 'items.product']);
167+
return Inertia::render('Inventory/PurchaseOrders/Receive', ['order' => $purchaseOrder]);
156168
}
157169

158-
public function receive(ReceivePurchaseOrderRequest $request, PurchaseOrder $purchaseOrder): RedirectResponse
170+
public function transition(Request $request, PurchaseOrder $purchaseOrder): RedirectResponse
159171
{
160-
try {
161-
$purchaseOrder->receive($request->validated()['lines']);
162-
} catch (\DomainException $e) {
163-
return back()->withErrors(['status' => $e->getMessage()]);
172+
$this->authorize('update', $purchaseOrder);
173+
$status = $request->input('status');
174+
if ($status) {
175+
$purchaseOrder->status = $status;
176+
$purchaseOrder->save();
164177
}
178+
return back()->with('success', 'Status updated.');
179+
}
165180

166-
return redirect()->route('inventory.purchase-orders.show', $purchaseOrder)
167-
->with('success', 'Items received and stock updated.');
181+
public function submit(PurchaseOrder $purchaseOrder): RedirectResponse
182+
{
183+
$this->authorize('update', $purchaseOrder);
184+
$purchaseOrder->send();
185+
return back()->with('success', 'Purchase order submitted.');
168186
}
169187

170-
public function cancel(PurchaseOrder $purchaseOrder): RedirectResponse
188+
public function approve(PurchaseOrder $purchaseOrder): RedirectResponse
171189
{
172-
try {
173-
$purchaseOrder->transitionTo('cancelled');
174-
} catch (\DomainException $e) {
175-
return back()->withErrors(['status' => $e->getMessage()]);
176-
}
190+
$this->authorize('update', $purchaseOrder);
191+
$purchaseOrder->status = 'sent';
192+
$purchaseOrder->save();
193+
return back()->with('success', 'Purchase order approved.');
194+
}
177195

178-
return back()->with('success', 'Purchase order cancelled.');
196+
public function destroy(PurchaseOrder $purchaseOrder): RedirectResponse
197+
{
198+
$this->authorize('delete', $purchaseOrder);
199+
$purchaseOrder->delete();
200+
201+
return redirect()->route('inventory.purchase-orders.index');
179202
}
180203
}

0 commit comments

Comments
 (0)