Skip to content

Commit 3b6bd4e

Browse files
committed
feat(inventory): Phase 145 — Inventory Goods Receipts
Add goods receipt lifecycle: draft → confirmed → posted/rejected, with line item tracking, unit cost, and policy-gated CRUD + action routes. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent af398e2 commit 3b6bd4e

13 files changed

Lines changed: 506 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Modules\Inventory\Models\GoodsReceipt;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class GoodsReceiptController
12+
{
13+
public function index(): Response
14+
{
15+
$receipts = GoodsReceipt::with('items')
16+
->orderByDesc('receipt_date')
17+
->paginate(20);
18+
19+
return Inertia::render('Inventory/GoodsReceipts/Index', compact('receipts'));
20+
}
21+
22+
public function create(): Response
23+
{
24+
return Inertia::render('Inventory/GoodsReceipts/Create');
25+
}
26+
27+
public function store(Request $request): RedirectResponse
28+
{
29+
$data = $request->validate([
30+
'supplier_name' => 'required|string|max:255',
31+
'receipt_date' => 'required|date',
32+
'supplier_reference' => 'nullable|string|max:255',
33+
'notes' => 'nullable|string',
34+
'warehouse_id' => 'nullable|exists:warehouses,id',
35+
]);
36+
37+
$data['tenant_id'] = app('tenant')->id;
38+
$data['created_by'] = auth()->id();
39+
40+
GoodsReceipt::create($data);
41+
42+
return redirect()->route('inventory.goods-receipts.index');
43+
}
44+
45+
public function show(GoodsReceipt $goodsReceipt): Response
46+
{
47+
$goodsReceipt->load('items.product');
48+
return Inertia::render('Inventory/GoodsReceipts/Show', ['receipt' => $goodsReceipt]);
49+
}
50+
51+
public function edit(GoodsReceipt $goodsReceipt): Response
52+
{
53+
return Inertia::render('Inventory/GoodsReceipts/Edit', ['receipt' => $goodsReceipt]);
54+
}
55+
56+
public function update(Request $request, GoodsReceipt $goodsReceipt): RedirectResponse
57+
{
58+
$data = $request->validate([
59+
'supplier_name' => 'required|string|max:255',
60+
'receipt_date' => 'required|date',
61+
'supplier_reference' => 'nullable|string|max:255',
62+
'notes' => 'nullable|string',
63+
]);
64+
65+
$goodsReceipt->update($data);
66+
67+
return redirect()->route('inventory.goods-receipts.index');
68+
}
69+
70+
public function destroy(GoodsReceipt $goodsReceipt): RedirectResponse
71+
{
72+
$goodsReceipt->delete();
73+
return redirect()->route('inventory.goods-receipts.index');
74+
}
75+
76+
public function confirm(GoodsReceipt $goodsReceipt): RedirectResponse
77+
{
78+
$goodsReceipt->confirm(auth()->id());
79+
return redirect()->route('inventory.goods-receipts.index');
80+
}
81+
82+
public function post(GoodsReceipt $goodsReceipt): RedirectResponse
83+
{
84+
$goodsReceipt->post();
85+
return redirect()->route('inventory.goods-receipts.index');
86+
}
87+
88+
public function reject(GoodsReceipt $goodsReceipt): RedirectResponse
89+
{
90+
$goodsReceipt->reject();
91+
return redirect()->route('inventory.goods-receipts.index');
92+
}
93+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 GoodsReceipt extends Model
14+
{
15+
use BelongsToTenant, SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'receipt_number',
20+
'supplier_name',
21+
'supplier_reference',
22+
'receipt_date',
23+
'status',
24+
'notes',
25+
'warehouse_id',
26+
'received_by',
27+
'confirmed_at',
28+
'created_by',
29+
];
30+
31+
protected $casts = [
32+
'receipt_date' => 'date',
33+
'confirmed_at' => 'datetime',
34+
];
35+
36+
protected $attributes = [
37+
'status' => 'draft',
38+
];
39+
40+
// Relations
41+
42+
public function items(): HasMany
43+
{
44+
return $this->hasMany(GoodsReceiptItem::class);
45+
}
46+
47+
public function warehouse(): BelongsTo
48+
{
49+
return $this->belongsTo(Warehouse::class);
50+
}
51+
52+
public function receiver(): BelongsTo
53+
{
54+
return $this->belongsTo(User::class, 'received_by');
55+
}
56+
57+
public function creator(): BelongsTo
58+
{
59+
return $this->belongsTo(User::class, 'created_by');
60+
}
61+
62+
// Methods
63+
64+
public function confirm(int $userId): void
65+
{
66+
if ($this->receipt_number === null) {
67+
$this->receipt_number = $this->generateReceiptNumber();
68+
}
69+
$this->status = 'confirmed';
70+
$this->confirmed_at = now();
71+
$this->received_by = $userId;
72+
$this->save();
73+
}
74+
75+
public function post(): void
76+
{
77+
$this->status = 'posted';
78+
$this->save();
79+
}
80+
81+
public function reject(): void
82+
{
83+
$this->status = 'rejected';
84+
$this->save();
85+
}
86+
87+
public function generateReceiptNumber(): string
88+
{
89+
return 'GR-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
90+
}
91+
92+
// Accessors
93+
94+
protected function isDraft(): Attribute
95+
{
96+
return Attribute::make(
97+
get: fn () => $this->status === 'draft',
98+
);
99+
}
100+
101+
protected function isConfirmed(): Attribute
102+
{
103+
return Attribute::make(
104+
get: fn () => $this->status === 'confirmed',
105+
);
106+
}
107+
108+
protected function totalItems(): Attribute
109+
{
110+
return Attribute::make(
111+
get: fn () => $this->items()->count(),
112+
);
113+
}
114+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use Illuminate\Database\Eloquent\Casts\Attribute;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class GoodsReceiptItem extends Model
10+
{
11+
protected $fillable = [
12+
'goods_receipt_id',
13+
'product_id',
14+
'quantity_expected',
15+
'quantity_received',
16+
'unit_cost',
17+
'condition',
18+
'notes',
19+
];
20+
21+
protected $casts = [
22+
'quantity_expected' => 'decimal:2',
23+
'quantity_received' => 'decimal:2',
24+
'unit_cost' => 'decimal:2',
25+
];
26+
27+
protected $attributes = [
28+
'condition' => 'good',
29+
'quantity_expected' => 0,
30+
'quantity_received' => 0,
31+
'unit_cost' => 0,
32+
];
33+
34+
// Relations
35+
36+
public function receipt(): BelongsTo
37+
{
38+
return $this->belongsTo(GoodsReceipt::class, 'goods_receipt_id');
39+
}
40+
41+
public function product(): BelongsTo
42+
{
43+
return $this->belongsTo(Product::class);
44+
}
45+
46+
// Accessors
47+
48+
protected function lineTotal(): Attribute
49+
{
50+
return Attribute::make(
51+
get: fn () => (float) $this->quantity_received * (float) $this->unit_cost,
52+
);
53+
}
54+
}
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\GoodsReceipt;
7+
8+
class GoodsReceiptPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, GoodsReceipt $goodsReceipt): 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, GoodsReceipt $goodsReceipt): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function confirm(User $user, GoodsReceipt $goodsReceipt): bool
31+
{
32+
return $user->can('inventory.create');
33+
}
34+
35+
public function post(User $user, GoodsReceipt $goodsReceipt): bool
36+
{
37+
return $user->can('inventory.create');
38+
}
39+
40+
public function reject(User $user, GoodsReceipt $goodsReceipt): bool
41+
{
42+
return $user->can('inventory.delete');
43+
}
44+
45+
public function delete(User $user, GoodsReceipt $goodsReceipt): 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
@@ -78,6 +78,9 @@
7878
use App\Modules\Inventory\Policies\QualityAlertPolicy;
7979
use App\Modules\Inventory\Models\PurchaseRequest;
8080
use App\Modules\Inventory\Policies\PurchaseRequestPolicy;
81+
use App\Modules\Inventory\Models\GoodsReceipt;
82+
use App\Modules\Inventory\Models\GoodsReceiptItem;
83+
use App\Modules\Inventory\Policies\GoodsReceiptPolicy;
8184
use App\Modules\Inventory\Models\StockReservation;
8285
use App\Modules\Inventory\Policies\StockReservationPolicy;
8386
use Illuminate\Support\Facades\Gate;
@@ -140,5 +143,7 @@ public function boot(): void
140143
Gate::policy(QualityAlert::class, QualityAlertPolicy::class);
141144
Gate::policy(StockReservation::class, StockReservationPolicy::class);
142145
Gate::policy(PurchaseRequest::class, PurchaseRequestPolicy::class);
146+
Gate::policy(GoodsReceipt::class, GoodsReceiptPolicy::class);
147+
Gate::policy(GoodsReceiptItem::class, GoodsReceiptPolicy::class);
143148
}
144149
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,12 @@
300300
Route::post('purchase-requests/{purchase_request}/cancel', [PurchaseRequestController::class, 'cancel'])->name('purchase-requests.cancel');
301301
Route::resource('purchase-requests', PurchaseRequestController::class);
302302
});
303+
304+
// Goods Receipts
305+
use App\Modules\Inventory\Http\Controllers\GoodsReceiptController;
306+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
307+
Route::post('goods-receipts/{goods_receipt}/confirm', [GoodsReceiptController::class, 'confirm'])->name('goods-receipts.confirm');
308+
Route::post('goods-receipts/{goods_receipt}/post', [GoodsReceiptController::class, 'post'])->name('goods-receipts.post');
309+
Route::post('goods-receipts/{goods_receipt}/reject', [GoodsReceiptController::class, 'reject'])->name('goods-receipts.reject');
310+
Route::resource('goods-receipts', GoodsReceiptController::class);
311+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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::dropIfExists('goods_receipts');
12+
Schema::create('goods_receipts', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->string('receipt_number')->nullable();
16+
$table->string('supplier_name');
17+
$table->string('supplier_reference')->nullable();
18+
$table->date('receipt_date');
19+
$table->string('status')->default('draft');
20+
$table->text('notes')->nullable();
21+
$table->foreignId('warehouse_id')->nullable()->constrained()->nullOnDelete();
22+
$table->foreignId('received_by')->nullable()->constrained('users')->nullOnDelete();
23+
$table->timestamp('confirmed_at')->nullable();
24+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
25+
$table->timestamps();
26+
$table->softDeletes();
27+
});
28+
}
29+
30+
public function down(): void
31+
{
32+
Schema::dropIfExists('goods_receipts');
33+
}
34+
};

0 commit comments

Comments
 (0)