Skip to content

Commit 32957df

Browse files
committed
feat(inventory): Phase 66 — Inventory Costing (FIFO / Average Cost)
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent c76c3d7 commit 32957df

15 files changed

Lines changed: 894 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\CostingLayer;
7+
use App\Modules\Inventory\Models\Product;
8+
use App\Modules\Inventory\Models\ProductCostSnapshot;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class CostingController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', CostingLayer::class);
19+
20+
$products = Product::withCount(['costingLayers' => fn ($q) => $q->where('quantity_remaining', '>', 0)])
21+
->orderBy('name')
22+
->paginate(20);
23+
24+
$filters = $request->only(['product_id']);
25+
26+
return Inertia::render('Inventory/Costing/Index', compact('products', 'filters'));
27+
}
28+
29+
public function layers(Request $request, Product $product): Response
30+
{
31+
$this->authorize('view', CostingLayer::class);
32+
33+
$layers = CostingLayer::forProduct($product->id)
34+
->withRemaining()
35+
->fifo()
36+
->paginate(20);
37+
38+
return Inertia::render('Inventory/Costing/Layers', compact('product', 'layers'));
39+
}
40+
41+
public function addLayer(Request $request): RedirectResponse
42+
{
43+
$this->authorize('create', CostingLayer::class);
44+
45+
$data = $request->validate([
46+
'product_id' => 'required|exists:products,id',
47+
'costing_method' => 'required|in:fifo,avco',
48+
'quantity' => 'required|numeric|min:0.0001',
49+
'unit_cost' => 'required|numeric|min:0',
50+
'received_at' => 'nullable|date',
51+
'reference_type' => 'nullable|string|max:50',
52+
]);
53+
54+
CostingLayer::create([
55+
'tenant_id' => auth()->user()->tenant_id,
56+
'product_id' => $data['product_id'],
57+
'costing_method' => $data['costing_method'],
58+
'quantity_received' => $data['quantity'],
59+
'quantity_remaining' => $data['quantity'],
60+
'unit_cost' => $data['unit_cost'],
61+
'received_at' => $data['received_at'] ?? now(),
62+
'reference_type' => $data['reference_type'] ?? null,
63+
]);
64+
65+
return back()->with('success', 'Costing layer added.');
66+
}
67+
68+
public function snapshot(Request $request): RedirectResponse
69+
{
70+
$this->authorize('create', CostingLayer::class);
71+
72+
$data = $request->validate([
73+
'product_id' => 'required|exists:products,id',
74+
]);
75+
76+
ProductCostSnapshot::takeSnapshot(auth()->user()->tenant_id, $data['product_id']);
77+
78+
return back()->with('success', 'Snapshot taken.');
79+
}
80+
81+
public function report(Request $request): Response
82+
{
83+
$this->authorize('viewAny', CostingLayer::class);
84+
85+
/** @var \App\Models\User $user */
86+
$user = auth()->user();
87+
88+
$products = Product::withCount(['costingLayers' => fn ($q) => $q->where('quantity_remaining', '>', 0)])
89+
->orderBy('name')
90+
->limit(50)
91+
->get();
92+
93+
$rows = $products->map(fn ($product) => [
94+
'product' => $product,
95+
'average_cost' => CostingLayer::getAverageCost($user->tenant_id, $product->id),
96+
'layers_count' => $product->costing_layers_count,
97+
])->all();
98+
99+
return Inertia::render('Inventory/Costing/Report', compact('rows'));
100+
}
101+
}
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\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class CostingLayer extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'costing_layers';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'product_id',
18+
'warehouse_id',
19+
'costing_method',
20+
'quantity_received',
21+
'quantity_remaining',
22+
'unit_cost',
23+
'received_at',
24+
'reference_type',
25+
'reference_id',
26+
];
27+
28+
protected $casts = [
29+
'quantity_received' => 'decimal:4',
30+
'quantity_remaining' => 'decimal:4',
31+
'unit_cost' => 'decimal:4',
32+
'received_at' => 'datetime',
33+
];
34+
35+
public function product(): BelongsTo
36+
{
37+
return $this->belongsTo(Product::class);
38+
}
39+
40+
public function warehouse(): BelongsTo
41+
{
42+
return $this->belongsTo(Warehouse::class);
43+
}
44+
45+
public function scopeForProduct($query, int $productId)
46+
{
47+
return $query->where('product_id', $productId);
48+
}
49+
50+
public function scopeWithRemaining($query)
51+
{
52+
return $query->where('quantity_remaining', '>', 0);
53+
}
54+
55+
public function scopeFifo($query)
56+
{
57+
return $query->orderBy('received_at', 'asc');
58+
}
59+
60+
public static function getAverageCost(int $tenantId, int $productId): float
61+
{
62+
$layers = static::withoutGlobalScope('tenant')
63+
->where('tenant_id', $tenantId)
64+
->where('product_id', $productId)
65+
->where('quantity_remaining', '>', 0)
66+
->get();
67+
68+
$totalQty = $layers->sum(fn ($l) => (float) $l->quantity_remaining);
69+
$totalValue = $layers->sum(fn ($l) => (float) $l->quantity_remaining * (float) $l->unit_cost);
70+
71+
if ($totalQty <= 0) {
72+
return 0.0;
73+
}
74+
75+
return $totalValue / $totalQty;
76+
}
77+
78+
public static function consumeFifo(int $tenantId, int $productId, float $quantity): float
79+
{
80+
$layers = static::withoutGlobalScope('tenant')
81+
->where('tenant_id', $tenantId)
82+
->where('product_id', $productId)
83+
->where('quantity_remaining', '>', 0)
84+
->orderBy('received_at', 'asc')
85+
->get();
86+
87+
$needed = $quantity;
88+
$totalCost = 0.0;
89+
90+
foreach ($layers as $layer) {
91+
if ($needed <= 0) {
92+
break;
93+
}
94+
95+
$layerRemaining = (float) $layer->quantity_remaining;
96+
$unitCost = (float) $layer->unit_cost;
97+
98+
if ($layerRemaining >= $needed) {
99+
$totalCost += $needed * $unitCost;
100+
$layer->quantity_remaining = $layerRemaining - $needed;
101+
$layer->save();
102+
$needed = 0;
103+
break;
104+
} else {
105+
$totalCost += $layerRemaining * $unitCost;
106+
$needed -= $layerRemaining;
107+
$layer->quantity_remaining = 0;
108+
$layer->save();
109+
}
110+
}
111+
112+
return $totalCost;
113+
}
114+
}

erp/app/Modules/Inventory/Models/Product.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,15 @@ public function scopeActive($query)
120120
{
121121
return $query->where('is_active', true);
122122
}
123+
124+
public function costingLayers(): HasMany
125+
{
126+
return $this->hasMany(CostingLayer::class);
127+
}
128+
129+
public function getAverageCostAttribute(): float
130+
{
131+
return CostingLayer::getAverageCost($this->tenant_id, $this->id);
132+
}
133+
123134
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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 ProductCostSnapshot extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'product_cost_snapshots';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'product_id',
18+
'costing_method',
19+
'average_cost',
20+
'fifo_cost',
21+
'snapshot_date',
22+
'total_quantity',
23+
'total_value',
24+
];
25+
26+
protected $casts = [
27+
'average_cost' => 'decimal:4',
28+
'fifo_cost' => 'decimal:4',
29+
'total_quantity' => 'decimal:4',
30+
'total_value' => 'decimal:4',
31+
'snapshot_date' => 'date',
32+
];
33+
34+
public function product(): BelongsTo
35+
{
36+
return $this->belongsTo(Product::class);
37+
}
38+
39+
public static function takeSnapshot(int $tenantId, int $productId): self
40+
{
41+
$averageCost = CostingLayer::getAverageCost($tenantId, $productId);
42+
43+
// FIFO cost is the unit cost of the oldest remaining layer
44+
$oldestLayer = CostingLayer::withoutGlobalScope('tenant')
45+
->where('tenant_id', $tenantId)
46+
->where('product_id', $productId)
47+
->where('quantity_remaining', '>', 0)
48+
->orderBy('received_at', 'asc')
49+
->first();
50+
51+
$fifoCost = $oldestLayer ? (float) $oldestLayer->unit_cost : 0.0;
52+
53+
$layers = CostingLayer::withoutGlobalScope('tenant')
54+
->where('tenant_id', $tenantId)
55+
->where('product_id', $productId)
56+
->where('quantity_remaining', '>', 0)
57+
->get();
58+
59+
$totalQuantity = $layers->sum(fn ($l) => (float) $l->quantity_remaining);
60+
$totalValue = $layers->sum(fn ($l) => (float) $l->quantity_remaining * (float) $l->unit_cost);
61+
62+
return static::create([
63+
'tenant_id' => $tenantId,
64+
'product_id' => $productId,
65+
'costing_method' => 'fifo',
66+
'average_cost' => $averageCost,
67+
'fifo_cost' => $fifoCost,
68+
'snapshot_date' => now()->toDateString(),
69+
'total_quantity' => $totalQuantity,
70+
'total_value' => $totalValue,
71+
]);
72+
}
73+
}
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 CostingPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->can('inventory.view');
12+
}
13+
14+
public function view(User $user): bool
15+
{
16+
return $user->can('inventory.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->can('inventory.create');
22+
}
23+
24+
public function update(User $user): bool
25+
{
26+
return $user->can('inventory.create');
27+
}
28+
29+
public function delete(User $user): bool
30+
{
31+
return $user->can('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
@@ -25,6 +25,9 @@
2525
use App\Modules\Inventory\Policies\StockAdjustmentPolicy;
2626
use App\Modules\Inventory\Policies\StockTransferPolicy;
2727
use App\Modules\Inventory\Policies\WarehouseTransferPolicy;
28+
use App\Modules\Inventory\Models\CostingLayer;
29+
use App\Modules\Inventory\Models\ProductCostSnapshot;
30+
use App\Modules\Inventory\Policies\CostingPolicy;
2831
use Illuminate\Support\Facades\Gate;
2932
use Illuminate\Support\ServiceProvider;
3033

@@ -51,5 +54,7 @@ public function boot(): void
5154
Gate::policy(QcChecklistItem::class, QcPolicy::class);
5255
Gate::policy(QcInspection::class, QcPolicy::class);
5356
Gate::policy(QcInspectionResult::class, QcPolicy::class);
57+
Gate::policy(CostingLayer::class, CostingPolicy::class);
58+
Gate::policy(ProductCostSnapshot::class, CostingPolicy::class);
5459
}
5560
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use App\Modules\Inventory\Http\Controllers\AssetController;
4+
use App\Modules\Inventory\Http\Controllers\CostingController;
45
use App\Modules\Inventory\Http\Controllers\AssetMaintenanceController;
56
use App\Modules\Inventory\Http\Controllers\CategoryController;
67
use App\Modules\Inventory\Http\Controllers\ProductBundleController;
@@ -113,4 +114,11 @@
113114
Route::patch('qc-inspections/{qcInspection}/results/{result}', [QcInspectionController::class, 'updateResult'])->name('qc-inspections.results.update');
114115
Route::post('qc-inspections/{qcInspection}/complete', [QcInspectionController::class, 'complete'])->name('qc-inspections.complete');
115116
Route::resource('qc-inspections', QcInspectionController::class)->except(['edit', 'update']);
117+
118+
// Inventory Costing
119+
Route::get('costing', [CostingController::class, 'index'])->name('costing.index');
120+
Route::get('costing/report', [CostingController::class, 'report'])->name('costing.report');
121+
Route::get('costing/{product}/layers', [CostingController::class, 'layers'])->name('costing.layers');
122+
Route::post('costing/add-layer', [CostingController::class, 'addLayer'])->name('costing.add-layer');
123+
Route::post('costing/snapshot', [CostingController::class, 'snapshot'])->name('costing.snapshot');
116124
});

0 commit comments

Comments
 (0)