Skip to content

Commit 112d54e

Browse files
committed
feat(finance): Phase 128 — Finance Budget Management
Adds budget_number, department, budget_type, total_amount, allocated_amount, spent_amount, approved_by, approved_at, start_date, end_date columns to the budgets table; introduces budget_line_items table for Phase 128 line item tracking; adds BudgetLineItem model; extends Budget model with activate(), close(), generateBudgetNumber(), recalculate() methods and remaining_amount, utilization_percent, is_active, is_exceeded accessors; updates BudgetController with edit/update routes and corrected activate(userId) call; updates BudgetPolicy with activate/close methods; adds Edit.tsx React stub; replaces BudgetTest with Phase 128 feature tests (10 tests, all passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent eb81f73 commit 112d54e

9 files changed

Lines changed: 351 additions & 94 deletions

File tree

erp/app/Modules/Finance/Http/Controllers/BudgetController.php

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -49,38 +49,52 @@ public function store(Request $request): RedirectResponse
4949
$this->authorize('create', Budget::class);
5050

5151
$validated = $request->validate([
52-
'name' => ['required', 'string', 'max:255'],
53-
'fiscal_year' => ['required', 'integer'],
54-
'period_type' => ['nullable', Rule::in(['annual', 'quarterly', 'monthly'])],
55-
'notes' => ['nullable', 'string'],
52+
'name' => ['required', 'string', 'max:255'],
53+
'fiscal_year' => ['required'],
54+
'total_amount' => ['required', 'numeric', 'min:0'],
55+
'department' => ['nullable', 'string', 'max:255'],
56+
'budget_type' => ['nullable', Rule::in(['annual', 'quarterly', 'monthly', 'project'])],
57+
'notes' => ['nullable', 'string'],
58+
'start_date' => ['nullable', 'date'],
59+
'end_date' => ['nullable', 'date'],
5660
]);
5761

5862
$budget = Budget::create([
59-
'tenant_id' => app('tenant')->id,
60-
'name' => $validated['name'],
61-
'fiscal_year' => $validated['fiscal_year'],
62-
'year' => $validated['fiscal_year'],
63-
'period_type' => $validated['period_type'] ?? 'annual',
64-
'notes' => $validated['notes'] ?? null,
65-
'status' => 'draft',
63+
'tenant_id' => app('tenant')->id,
64+
'created_by' => auth()->id(),
65+
'name' => $validated['name'],
66+
'fiscal_year' => $validated['fiscal_year'],
67+
'year' => $validated['fiscal_year'],
68+
'total_amount' => $validated['total_amount'],
69+
'department' => $validated['department'] ?? null,
70+
'budget_type' => $validated['budget_type'] ?? 'annual',
71+
'period_type' => $validated['budget_type'] ?? 'annual',
72+
'notes' => $validated['notes'] ?? null,
73+
'start_date' => $validated['start_date'] ?? null,
74+
'end_date' => $validated['end_date'] ?? null,
75+
'status' => 'draft',
6676
]);
6777

68-
return redirect()->route('finance.budgets.show', $budget);
78+
return redirect()->route('finance.budgets.index');
6979
}
7080

7181
public function show(Budget $budget): Response
7282
{
7383
$this->authorize('view', $budget);
7484

75-
$budget->load('lines');
85+
$budget->load('lines', 'lineItems');
7686

7787
return Inertia::render('Finance/Budgets/Show', [
7888
'budget' => array_merge($budget->toArray(), [
79-
'total_budgeted' => $budget->total_budgeted,
80-
'total_actual' => $budget->total_actual,
81-
'total_variance' => $budget->total_variance,
82-
'variance_percent' => $budget->variance_percent,
83-
'lines' => $budget->lines->map(fn ($line) => array_merge($line->toArray(), [
89+
'total_budgeted' => $budget->total_budgeted,
90+
'total_actual' => $budget->total_actual,
91+
'total_variance' => $budget->total_variance,
92+
'variance_percent' => $budget->variance_percent,
93+
'remaining_amount' => $budget->remaining_amount,
94+
'utilization_percent' => $budget->utilization_percent,
95+
'is_active' => $budget->is_active,
96+
'is_exceeded' => $budget->is_exceeded,
97+
'lines' => $budget->lines->map(fn ($line) => array_merge($line->toArray(), [
8498
'variance' => $line->variance,
8599
'variance_percent' => $line->variance_percent,
86100
'is_over_budget' => $line->is_over_budget,
@@ -89,6 +103,46 @@ public function show(Budget $budget): Response
89103
]);
90104
}
91105

106+
public function edit(Budget $budget): Response
107+
{
108+
$this->authorize('update', $budget);
109+
110+
return Inertia::render('Finance/Budgets/Edit', [
111+
'budget' => $budget,
112+
]);
113+
}
114+
115+
public function update(Request $request, Budget $budget): RedirectResponse
116+
{
117+
$this->authorize('update', $budget);
118+
119+
$validated = $request->validate([
120+
'name' => ['required', 'string', 'max:255'],
121+
'fiscal_year' => ['required'],
122+
'total_amount' => ['required', 'numeric', 'min:0'],
123+
'department' => ['nullable', 'string', 'max:255'],
124+
'budget_type' => ['nullable', Rule::in(['annual', 'quarterly', 'monthly', 'project'])],
125+
'notes' => ['nullable', 'string'],
126+
'start_date' => ['nullable', 'date'],
127+
'end_date' => ['nullable', 'date'],
128+
]);
129+
130+
$budget->update([
131+
'name' => $validated['name'],
132+
'fiscal_year' => $validated['fiscal_year'],
133+
'year' => $validated['fiscal_year'],
134+
'total_amount' => $validated['total_amount'],
135+
'department' => $validated['department'] ?? null,
136+
'budget_type' => $validated['budget_type'] ?? $budget->budget_type,
137+
'period_type' => $validated['budget_type'] ?? $budget->period_type,
138+
'notes' => $validated['notes'] ?? null,
139+
'start_date' => $validated['start_date'] ?? null,
140+
'end_date' => $validated['end_date'] ?? null,
141+
]);
142+
143+
return redirect()->route('finance.budgets.index');
144+
}
145+
92146
public function destroy(Budget $budget): RedirectResponse
93147
{
94148
$this->authorize('delete', $budget);
@@ -102,7 +156,7 @@ public function activate(Request $request, Budget $budget): RedirectResponse
102156
{
103157
$this->authorize('update', $budget);
104158

105-
$budget->activate();
159+
$budget->activate(auth()->id());
106160

107161
return redirect()->back();
108162
}

erp/app/Modules/Finance/Models/Budget.php

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,54 @@ class Budget extends Model
1313
use SoftDeletes;
1414

1515
protected $fillable = [
16-
'tenant_id', 'name', 'fiscal_year', 'year', 'period_type', 'notes', 'status', 'created_by',
16+
'tenant_id', 'name', 'budget_number', 'fiscal_year', 'year', 'period_type',
17+
'department', 'budget_type', 'total_amount', 'allocated_amount', 'spent_amount',
18+
'status', 'notes', 'approved_by', 'approved_at', 'start_date', 'end_date',
19+
'created_by',
1720
];
1821

1922
protected $casts = [
20-
'fiscal_year' => 'integer',
21-
'year' => 'integer',
23+
'fiscal_year' => 'integer',
24+
'year' => 'integer',
25+
'total_amount' => 'decimal:2',
26+
'allocated_amount' => 'decimal:2',
27+
'spent_amount' => 'decimal:2',
28+
'approved_at' => 'datetime',
29+
'start_date' => 'date',
30+
'end_date' => 'date',
2231
];
2332

33+
protected $attributes = [
34+
'status' => 'draft',
35+
'total_amount' => 0,
36+
'allocated_amount' => 0,
37+
'spent_amount' => 0,
38+
];
39+
40+
// ─── Relations ────────────────────────────────────────────────────────────
41+
2442
public function lines(): HasMany
2543
{
2644
return $this->hasMany(BudgetLine::class);
2745
}
2846

29-
public function activate(): void
47+
public function lineItems(): HasMany
3048
{
31-
$this->status = 'active';
49+
return $this->hasMany(BudgetLineItem::class);
50+
}
51+
52+
// ─── Actions ──────────────────────────────────────────────────────────────
53+
54+
public function activate(int $userId): void
55+
{
56+
$this->status = 'active';
57+
$this->approved_by = $userId;
58+
$this->approved_at = now();
59+
60+
if (is_null($this->budget_number)) {
61+
$this->budget_number = $this->generateBudgetNumber();
62+
}
63+
3264
$this->save();
3365
}
3466

@@ -38,6 +70,54 @@ public function close(): void
3870
$this->save();
3971
}
4072

73+
public function generateBudgetNumber(): string
74+
{
75+
return 'BDG-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
76+
}
77+
78+
public function recalculate(): void
79+
{
80+
$spent = (float) $this->lineItems()->sum('actual_amount');
81+
82+
$this->spent_amount = $spent;
83+
84+
if ($spent >= (float) $this->total_amount) {
85+
$this->status = 'exceeded';
86+
}
87+
88+
$this->save();
89+
}
90+
91+
// ─── Accessors ────────────────────────────────────────────────────────────
92+
93+
public function getRemainingAmountAttribute(): float
94+
{
95+
return (float) $this->total_amount - (float) $this->spent_amount;
96+
}
97+
98+
public function getUtilizationPercentAttribute(): float
99+
{
100+
$total = (float) $this->total_amount;
101+
102+
if ($total <= 0) {
103+
return 0.0;
104+
}
105+
106+
return round(((float) $this->spent_amount / $total) * 100, 2);
107+
}
108+
109+
public function getIsActiveAttribute(): bool
110+
{
111+
return $this->status === 'active';
112+
}
113+
114+
public function getIsExceededAttribute(): bool
115+
{
116+
return $this->status === 'exceeded';
117+
}
118+
119+
// ─── Legacy accessors (Phase 82) ──────────────────────────────────────────
120+
41121
public function getTotalBudgetedAttribute(): float
42122
{
43123
return (float) $this->lines->sum('budgeted_amount');
@@ -56,9 +136,11 @@ public function getTotalVarianceAttribute(): float
56136
public function getVariancePercentAttribute(): float
57137
{
58138
$budgeted = $this->total_budgeted;
139+
59140
if ($budgeted == 0) {
60141
return 0.0;
61142
}
143+
62144
return round(($this->total_variance / abs($budgeted)) * 100, 1);
63145
}
64146
}
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\Finance\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class BudgetLineItem extends Model
9+
{
10+
protected $fillable = [
11+
'budget_id', 'category', 'description', 'planned_amount', 'actual_amount',
12+
];
13+
14+
protected $casts = [
15+
'planned_amount' => 'decimal:2',
16+
'actual_amount' => 'decimal:2',
17+
];
18+
19+
protected $attributes = [
20+
'planned_amount' => 0,
21+
'actual_amount' => 0,
22+
];
23+
24+
public function budget(): BelongsTo
25+
{
26+
return $this->belongsTo(Budget::class);
27+
}
28+
29+
public function getVarianceAttribute(): float
30+
{
31+
return (float) $this->planned_amount - (float) $this->actual_amount;
32+
}
33+
}

erp/app/Modules/Finance/Policies/BudgetPolicy.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,14 @@ public function delete(User $user, $model): bool
3030
{
3131
return $user->hasPermissionTo('finance.delete');
3232
}
33+
34+
public function activate(User $user, $model): bool
35+
{
36+
return $user->hasPermissionTo('finance.create');
37+
}
38+
39+
public function close(User $user, $model): bool
40+
{
41+
return $user->hasPermissionTo('finance.create');
42+
}
3343
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@
168168
Route::post('budgets/{budget}/lines', [BudgetController::class, 'addLine'])->name('budgets.lines.add');
169169
Route::patch('budgets/{budget}/lines/{line}/actual', [BudgetController::class, 'updateActual'])->name('budgets.lines.actual');
170170
Route::delete('budgets/{budget}/lines/{line}', [BudgetController::class, 'removeLine'])->name('budgets.lines.remove');
171-
Route::resource('budgets', BudgetController::class)->except(['edit', 'update']);
171+
Route::resource('budgets', BudgetController::class);
172172

173173
// Budget Lines (legacy routes kept for backwards compatibility)
174174
Route::patch('budget-lines/{budgetLine}', [BudgetLineController::class, 'update'])->name('budget-lines.update');
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
// Add Phase 128 columns to the existing budgets table.
12+
Schema::table('budgets', function (Blueprint $table) {
13+
if (!Schema::hasColumn('budgets', 'budget_number')) {
14+
$table->string('budget_number')->nullable()->after('name');
15+
}
16+
if (!Schema::hasColumn('budgets', 'department')) {
17+
$table->string('department')->nullable()->after('budget_number');
18+
}
19+
if (!Schema::hasColumn('budgets', 'budget_type')) {
20+
$table->string('budget_type')->default('annual')->after('department');
21+
}
22+
if (!Schema::hasColumn('budgets', 'total_amount')) {
23+
$table->decimal('total_amount', 15, 2)->default(0)->after('budget_type');
24+
}
25+
if (!Schema::hasColumn('budgets', 'allocated_amount')) {
26+
$table->decimal('allocated_amount', 15, 2)->default(0)->after('total_amount');
27+
}
28+
if (!Schema::hasColumn('budgets', 'spent_amount')) {
29+
$table->decimal('spent_amount', 15, 2)->default(0)->after('allocated_amount');
30+
}
31+
if (!Schema::hasColumn('budgets', 'approved_by')) {
32+
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete()->after('spent_amount');
33+
}
34+
if (!Schema::hasColumn('budgets', 'approved_at')) {
35+
$table->timestamp('approved_at')->nullable()->after('approved_by');
36+
}
37+
if (!Schema::hasColumn('budgets', 'start_date')) {
38+
$table->date('start_date')->nullable()->after('approved_at');
39+
}
40+
if (!Schema::hasColumn('budgets', 'end_date')) {
41+
$table->date('end_date')->nullable()->after('start_date');
42+
}
43+
});
44+
}
45+
46+
public function down(): void
47+
{
48+
Schema::table('budgets', function (Blueprint $table) {
49+
foreach ([
50+
'budget_number', 'department', 'budget_type',
51+
'total_amount', 'allocated_amount', 'spent_amount',
52+
'approved_by', 'approved_at', 'start_date', 'end_date',
53+
] as $col) {
54+
if (Schema::hasColumn('budgets', $col)) {
55+
$table->dropColumn($col);
56+
}
57+
}
58+
});
59+
}
60+
};

0 commit comments

Comments
 (0)