浏览代码

wip budgets

3.x
Andrew Wallo 7 个月前
父节点
当前提交
94daec64bb

+ 37
- 0
app/Enums/Accounting/BudgetStatus.php 查看文件

@@ -0,0 +1,37 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum BudgetStatus: string implements HasColor, HasLabel
9
+{
10
+    case Draft = 'draft';
11
+    case Active = 'active';
12
+    case Closed = 'closed';
13
+
14
+    public function getLabel(): ?string
15
+    {
16
+        return $this->name;
17
+    }
18
+
19
+    public function getColor(): string | array | null
20
+    {
21
+        return match ($this) {
22
+            self::Draft => 'gray',
23
+            self::Active => 'success',
24
+            self::Closed => 'warning',
25
+        };
26
+    }
27
+
28
+    public function isEditable(): bool
29
+    {
30
+        return in_array($this, [self::Draft, self::Active]);
31
+    }
32
+
33
+    public static function editableStatuses(): array
34
+    {
35
+        return [self::Draft, self::Active];
36
+    }
37
+}

+ 272
- 0
app/Models/Accounting/Budget.php 查看文件

@@ -0,0 +1,272 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Accounting\BudgetStatus;
8
+use Filament\Actions\Action;
9
+use Filament\Actions\MountableAction;
10
+use Filament\Actions\ReplicateAction;
11
+use Illuminate\Database\Eloquent\Builder;
12
+use Illuminate\Database\Eloquent\Casts\Attribute;
13
+use Illuminate\Database\Eloquent\Factories\HasFactory;
14
+use Illuminate\Database\Eloquent\Model;
15
+use Illuminate\Database\Eloquent\Relations\HasMany;
16
+use Illuminate\Support\Carbon;
17
+
18
+class Budget extends Model
19
+{
20
+    use Blamable;
21
+    use CompanyOwned;
22
+    use HasFactory;
23
+
24
+    protected $fillable = [
25
+        'company_id',
26
+        'name',
27
+        'start_date',
28
+        'end_date',
29
+        'status', // draft, active, closed
30
+        'interval_type', // day, week, month, quarter, year
31
+        'notes',
32
+        'approved_at',
33
+        'closed_at',
34
+        'created_by',
35
+        'updated_by',
36
+    ];
37
+
38
+    protected $casts = [
39
+        'start_date' => 'date',
40
+        'end_date' => 'date',
41
+        'status' => BudgetStatus::class,
42
+        'approved_at' => 'datetime',
43
+        'closed_at' => 'datetime',
44
+    ];
45
+
46
+    public function budgetItems(): HasMany
47
+    {
48
+        return $this->hasMany(BudgetItem::class);
49
+    }
50
+
51
+    public function isDraft(): bool
52
+    {
53
+        return $this->status === BudgetStatus::Draft;
54
+    }
55
+
56
+    public function isActive(): bool
57
+    {
58
+        return $this->status === BudgetStatus::Active;
59
+    }
60
+
61
+    public function isClosed(): bool
62
+    {
63
+        return $this->status === BudgetStatus::Closed;
64
+    }
65
+
66
+    public function wasApproved(): bool
67
+    {
68
+        return $this->approved_at !== null;
69
+    }
70
+
71
+    public function wasClosed(): bool
72
+    {
73
+        return $this->closed_at !== null;
74
+    }
75
+
76
+    public function canBeApproved(): bool
77
+    {
78
+        return $this->isDraft() && ! $this->wasApproved();
79
+    }
80
+
81
+    public function canBeClosed(): bool
82
+    {
83
+        return $this->isActive() && ! $this->wasClosed();
84
+    }
85
+
86
+    public function hasItems(): bool
87
+    {
88
+        return $this->budgetItems()->exists();
89
+    }
90
+
91
+    public function scopeDraft(Builder $query): Builder
92
+    {
93
+        return $query->where('status', BudgetStatus::Draft);
94
+    }
95
+
96
+    public function scopeActive(Builder $query): Builder
97
+    {
98
+        return $query->where('status', BudgetStatus::Active);
99
+    }
100
+
101
+    public function scopeClosed(Builder $query): Builder
102
+    {
103
+        return $query->where('status', BudgetStatus::Closed);
104
+    }
105
+
106
+    public function scopeCurrentlyActive(Builder $query): Builder
107
+    {
108
+        return $query->active()
109
+            ->where('start_date', '<=', now())
110
+            ->where('end_date', '>=', now());
111
+    }
112
+
113
+    protected function isCurrentlyInPeriod(): Attribute
114
+    {
115
+        return Attribute::get(function () {
116
+            return now()->between($this->start_date, $this->end_date);
117
+        });
118
+    }
119
+
120
+    /**
121
+     * Approve a draft budget
122
+     */
123
+    public function approveDraft(?Carbon $approvedAt = null): void
124
+    {
125
+        if (! $this->canBeApproved()) {
126
+            throw new \RuntimeException('Budget cannot be approved.');
127
+        }
128
+
129
+        $approvedAt ??= now();
130
+
131
+        $this->update([
132
+            'status' => BudgetStatus::Active,
133
+            'approved_at' => $approvedAt,
134
+        ]);
135
+    }
136
+
137
+    /**
138
+     * Close an active budget
139
+     */
140
+    public function close(?Carbon $closedAt = null): void
141
+    {
142
+        if (! $this->canBeClosed()) {
143
+            throw new \RuntimeException('Budget cannot be closed.');
144
+        }
145
+
146
+        $closedAt ??= now();
147
+
148
+        $this->update([
149
+            'status' => BudgetStatus::Closed,
150
+            'closed_at' => $closedAt,
151
+        ]);
152
+    }
153
+
154
+    /**
155
+     * Reopen a closed budget
156
+     */
157
+    public function reopen(): void
158
+    {
159
+        if (! $this->isClosed()) {
160
+            throw new \RuntimeException('Only closed budgets can be reopened.');
161
+        }
162
+
163
+        $this->update([
164
+            'status' => BudgetStatus::Active,
165
+            'closed_at' => null,
166
+        ]);
167
+    }
168
+
169
+    /**
170
+     * Get Action for approving a draft budget
171
+     */
172
+    public static function getApproveDraftAction(string $action = Action::class): MountableAction
173
+    {
174
+        return $action::make('approveDraft')
175
+            ->label('Approve')
176
+            ->icon('heroicon-m-check-circle')
177
+            ->visible(function (self $record) {
178
+                return $record->canBeApproved();
179
+            })
180
+            ->databaseTransaction()
181
+            ->successNotificationTitle('Budget approved')
182
+            ->action(function (self $record, MountableAction $action) {
183
+                $record->approveDraft();
184
+                $action->success();
185
+            });
186
+    }
187
+
188
+    /**
189
+     * Get Action for closing an active budget
190
+     */
191
+    public static function getCloseAction(string $action = Action::class): MountableAction
192
+    {
193
+        return $action::make('close')
194
+            ->label('Close')
195
+            ->icon('heroicon-m-lock-closed')
196
+            ->color('warning')
197
+            ->visible(function (self $record) {
198
+                return $record->canBeClosed();
199
+            })
200
+            ->requiresConfirmation()
201
+            ->databaseTransaction()
202
+            ->successNotificationTitle('Budget closed')
203
+            ->action(function (self $record, MountableAction $action) {
204
+                $record->close();
205
+                $action->success();
206
+            });
207
+    }
208
+
209
+    /**
210
+     * Get Action for reopening a closed budget
211
+     */
212
+    public static function getReopenAction(string $action = Action::class): MountableAction
213
+    {
214
+        return $action::make('reopen')
215
+            ->label('Reopen')
216
+            ->icon('heroicon-m-lock-open')
217
+            ->visible(function (self $record) {
218
+                return $record->isClosed();
219
+            })
220
+            ->requiresConfirmation()
221
+            ->databaseTransaction()
222
+            ->successNotificationTitle('Budget reopened')
223
+            ->action(function (self $record, MountableAction $action) {
224
+                $record->reopen();
225
+                $action->success();
226
+            });
227
+    }
228
+
229
+    /**
230
+     * Get Action for duplicating a budget
231
+     */
232
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
233
+    {
234
+        return $action::make()
235
+            ->excludeAttributes([
236
+                'status',
237
+                'approved_at',
238
+                'closed_at',
239
+                'created_by',
240
+                'updated_by',
241
+                'created_at',
242
+                'updated_at',
243
+            ])
244
+            ->modal(false)
245
+            ->beforeReplicaSaved(function (self $original, self $replica) {
246
+                $replica->status = BudgetStatus::Draft;
247
+                $replica->name = $replica->name . ' (Copy)';
248
+                // Optionally adjust dates for the new budget period
249
+                // $replica->start_date = now()->startOfMonth();
250
+                // $replica->end_date = now()->endOfYear();
251
+            })
252
+            ->databaseTransaction()
253
+            ->after(function (self $original, self $replica) {
254
+                // Clone budget items
255
+                $original->budgetItems->each(function (BudgetItem $item) use ($replica) {
256
+                    $newItem = $item->replicate([
257
+                        'budget_id',
258
+                        'created_by',
259
+                        'updated_by',
260
+                        'created_at',
261
+                        'updated_at',
262
+                    ]);
263
+
264
+                    $newItem->budget_id = $replica->id;
265
+                    $newItem->save();
266
+                });
267
+            })
268
+            ->successRedirectUrl(static function (self $replica) {
269
+                return BudgetResource::getUrl('edit', ['record' => $replica]);
270
+            });
271
+    }
272
+}

+ 89
- 0
app/Models/Accounting/BudgetItem.php 查看文件

@@ -0,0 +1,89 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Concerns\Blamable;
7
+use App\Concerns\CompanyOwned;
8
+use Illuminate\Database\Eloquent\Casts\Attribute;
9
+use Illuminate\Database\Eloquent\Factories\HasFactory;
10
+use Illuminate\Database\Eloquent\Model;
11
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
12
+
13
+class BudgetItem extends Model
14
+{
15
+    use Blamable;
16
+    use CompanyOwned;
17
+    use HasFactory;
18
+
19
+    protected $fillable = [
20
+        'company_id',
21
+        'budget_id',
22
+        'account_id',
23
+        'start_date',
24
+        'end_date',
25
+        'amount', // in cents
26
+        'created_by',
27
+        'updated_by',
28
+    ];
29
+
30
+    protected $casts = [
31
+        'start_date' => 'date',
32
+        'end_date' => 'date',
33
+        'amount' => MoneyCast::class,
34
+    ];
35
+
36
+    public function budget(): BelongsTo
37
+    {
38
+        return $this->belongsTo(Budget::class);
39
+    }
40
+
41
+    public function account(): BelongsTo
42
+    {
43
+        return $this->belongsTo(Account::class);
44
+    }
45
+
46
+    /**
47
+     * Get actual transaction amount for this account and date range.
48
+     */
49
+    protected function actualAmount(): Attribute
50
+    {
51
+        return Attribute::make(
52
+            get: function () {
53
+                return Transaction::whereHas('journalEntries', function ($query) {
54
+                    $query->where('account_id', $this->account_id);
55
+                })
56
+                    ->whereBetween('posted_at', [$this->start_date, $this->end_date])
57
+                    ->sum('amount');
58
+            }
59
+        );
60
+    }
61
+
62
+    /**
63
+     * Get variance (budget - actual) for this budget item.
64
+     */
65
+    protected function variance(): Attribute
66
+    {
67
+        return Attribute::make(
68
+            get: function () {
69
+                return $this->amount - $this->actualAmount;
70
+            }
71
+        );
72
+    }
73
+
74
+    /**
75
+     * Get variance percentage for this budget item.
76
+     */
77
+    protected function variancePercentage(): Attribute
78
+    {
79
+        return Attribute::make(
80
+            get: function () {
81
+                if ($this->amount == 0) {
82
+                    return 0;
83
+                }
84
+
85
+                return ($this->variance / $this->amount) * 100;
86
+            }
87
+        );
88
+    }
89
+}

+ 23
- 0
database/factories/Accounting/BudgetFactory.php 查看文件

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use Illuminate\Database\Eloquent\Factories\Factory;
6
+
7
+/**
8
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\Budget>
9
+ */
10
+class BudgetFactory extends Factory
11
+{
12
+    /**
13
+     * Define the model's default state.
14
+     *
15
+     * @return array<string, mixed>
16
+     */
17
+    public function definition(): array
18
+    {
19
+        return [
20
+            //
21
+        ];
22
+    }
23
+}

+ 23
- 0
database/factories/Accounting/BudgetItemFactory.php 查看文件

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use Illuminate\Database\Eloquent\Factories\Factory;
6
+
7
+/**
8
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\BudgetItem>
9
+ */
10
+class BudgetItemFactory extends Factory
11
+{
12
+    /**
13
+     * Define the model's default state.
14
+     *
15
+     * @return array<string, mixed>
16
+     */
17
+    public function definition(): array
18
+    {
19
+        return [
20
+            //
21
+        ];
22
+    }
23
+}

+ 38
- 0
database/migrations/2025_03_15_191245_create_budgets_table.php 查看文件

@@ -0,0 +1,38 @@
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
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('budgets', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->string('name');
18
+            $table->date('start_date');
19
+            $table->date('end_date');
20
+            $table->string('status')->default('active'); // draft, active, closed
21
+            $table->string('interval_type')->default('month'); // day, week, month, quarter, year
22
+            $table->text('notes')->nullable();
23
+            $table->timestamp('approved_at')->nullable();
24
+            $table->timestamp('closed_at')->nullable();
25
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
26
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
27
+            $table->timestamps();
28
+        });
29
+    }
30
+
31
+    /**
32
+     * Reverse the migrations.
33
+     */
34
+    public function down(): void
35
+    {
36
+        Schema::dropIfExists('budgets');
37
+    }
38
+};

+ 35
- 0
database/migrations/2025_03_15_191321_create_budget_items_table.php 查看文件

@@ -0,0 +1,35 @@
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
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('budget_items', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('budget_id')->constrained()->cascadeOnDelete();
18
+            $table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
19
+            $table->date('start_date');
20
+            $table->date('end_date');
21
+            $table->bigInteger('amount')->default(0); // in cents
22
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
23
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
24
+            $table->timestamps();
25
+        });
26
+    }
27
+
28
+    /**
29
+     * Reverse the migrations.
30
+     */
31
+    public function down(): void
32
+    {
33
+        Schema::dropIfExists('budget_items');
34
+    }
35
+};

正在加载...
取消
保存