Преглед на файлове

wip adjustment statuses

3.x
Andrew Wallo преди 6 месеца
родител
ревизия
e531917593

+ 18
- 1
app/Enums/Accounting/AdjustmentStatus.php Целия файл

@@ -10,6 +10,7 @@ enum AdjustmentStatus: string implements HasColor, HasLabel
10 10
     case Active = 'active';
11 11
     case Upcoming = 'upcoming';
12 12
     case Expired = 'expired';
13
+    case Paused = 'paused';
13 14
     case Archived = 'archived';
14 15
 
15 16
     public function getLabel(): ?string
@@ -21,9 +22,25 @@ enum AdjustmentStatus: string implements HasColor, HasLabel
21 22
     {
22 23
         return match ($this) {
23 24
             self::Active => 'primary',
24
-            self::Upcoming => 'warning',
25
+            self::Upcoming, self::Paused => 'warning',
25 26
             self::Expired => 'danger',
26 27
             self::Archived => 'gray',
27 28
         };
28 29
     }
30
+
31
+    /**
32
+     * Check if the status is set manually (not calculated from dates)
33
+     */
34
+    public function isManualStatus(): bool
35
+    {
36
+        return in_array($this, [self::Paused, self::Archived]);
37
+    }
38
+
39
+    /**
40
+     * Check if the status is system-calculated based on dates
41
+     */
42
+    public function isSystemStatus(): bool
43
+    {
44
+        return in_array($this, [self::Active, self::Upcoming, self::Expired]);
45
+    }
29 46
 }

+ 141
- 6
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php Целия файл

@@ -5,16 +5,17 @@ namespace App\Filament\Company\Clusters\Settings\Resources;
5 5
 use App\Enums\Accounting\AdjustmentCategory;
6 6
 use App\Enums\Accounting\AdjustmentComputation;
7 7
 use App\Enums\Accounting\AdjustmentScope;
8
-use App\Enums\Accounting\AdjustmentStatus;
9 8
 use App\Enums\Accounting\AdjustmentType;
10 9
 use App\Filament\Company\Clusters\Settings;
11 10
 use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
12 11
 use App\Models\Accounting\Adjustment;
13 12
 use Filament\Forms;
14 13
 use Filament\Forms\Form;
14
+use Filament\Notifications\Notification;
15 15
 use Filament\Resources\Resource;
16 16
 use Filament\Tables;
17 17
 use Filament\Tables\Table;
18
+use Illuminate\Database\Eloquent\Collection;
18 19
 
19 20
 class AdjustmentResource extends Resource
20 21
 {
@@ -110,17 +111,151 @@ class AdjustmentResource extends Resource
110 111
             ->actions([
111 112
                 Tables\Actions\ActionGroup::make([
112 113
                     Tables\Actions\EditAction::make(),
114
+                    Tables\Actions\Action::make('pause')
115
+                        ->label('Pause')
116
+                        ->icon('heroicon-m-pause')
117
+                        ->color('warning')
118
+                        ->form([
119
+                            Forms\Components\DateTimePicker::make('paused_until')
120
+                                ->label('Auto-resume Date')
121
+                                ->helperText('When should this adjustment automatically resume? Leave empty to keep paused indefinitely.')
122
+                                ->after('now'),
123
+                            Forms\Components\Textarea::make('status_reason')
124
+                                ->label('Reason for Pausing')
125
+                                ->maxLength(255),
126
+                        ])
127
+                        ->visible(fn (Adjustment $record) => $record->canBePaused())
128
+                        ->action(function (Adjustment $record, array $data) {
129
+                            $pausedUntil = $data['paused_until'] ?? null;
130
+                            $reason = $data['status_reason'] ?? null;
131
+                            $record->pause($reason, $pausedUntil);
132
+                        }),
133
+                    Tables\Actions\Action::make('resume')
134
+                        ->label('Resume')
135
+                        ->icon('heroicon-m-play')
136
+                        ->color('success')
137
+                        ->requiresConfirmation()
138
+                        ->visible(fn (Adjustment $record) => $record->canBeResumed())
139
+                        ->action(fn (Adjustment $record) => $record->resume()),
113 140
                     Tables\Actions\Action::make('archive')
114 141
                         ->label('Archive')
115
-                        ->icon('heroicon-o-archive-box-x-mark')
142
+                        ->icon('heroicon-m-archive-box')
116 143
                         ->color('danger')
117
-                        ->requiresConfirmation()
118
-                        ->visible(static fn (Adjustment $record) => $record->status !== AdjustmentStatus::Archived)
119
-                        ->action(fn (Adjustment $record) => $record->update(['status' => AdjustmentStatus::Archived])),
144
+                        ->form([
145
+                            Forms\Components\Textarea::make('status_reason')
146
+                                ->label('Reason for Archiving')
147
+                                ->maxLength(255),
148
+                        ])
149
+                        ->visible(fn (Adjustment $record) => $record->canBeArchived())
150
+                        ->action(function (Adjustment $record, array $data) {
151
+                            $reason = $data['status_reason'] ?? null;
152
+                            $record->archive($reason);
153
+                        }),
120 154
                 ]),
121 155
             ])
122 156
             ->bulkActions([
123
-                //
157
+                Tables\Actions\BulkActionGroup::make([
158
+                    Tables\Actions\BulkAction::make('pause')
159
+                        ->label('Pause')
160
+                        ->icon('heroicon-m-pause')
161
+                        ->color('warning')
162
+                        ->form([
163
+                            Forms\Components\DateTimePicker::make('paused_until')
164
+                                ->label('Auto-resume Date')
165
+                                ->helperText('When should these adjustments automatically resume? Leave empty to keep paused indefinitely.')
166
+                                ->after('now'),
167
+                            Forms\Components\Textarea::make('status_reason')
168
+                                ->label('Reason for Pausing')
169
+                                ->maxLength(255),
170
+                        ])
171
+                        ->databaseTransaction()
172
+                        ->successNotificationTitle('Adjustments paused')
173
+                        ->failureNotificationTitle('Failed to pause adjustments')
174
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
175
+                            $isInvalid = $records->contains(fn (Adjustment $record) => ! $record->canBePaused());
176
+
177
+                            if ($isInvalid) {
178
+                                Notification::make()
179
+                                    ->title('Pause failed')
180
+                                    ->body('Only adjustments that are currently active can be paused. Please adjust your selection and try again.')
181
+                                    ->persistent()
182
+                                    ->danger()
183
+                                    ->send();
184
+
185
+                                $action->cancel(true);
186
+                            }
187
+                        })
188
+                        ->deselectRecordsAfterCompletion()
189
+                        ->action(function (Collection $records, array $data) {
190
+                            $pausedUntil = $data['paused_until'] ?? null;
191
+                            $reason = $data['status_reason'] ?? null;
192
+
193
+                            $records->each(function (Adjustment $record) use ($reason, $pausedUntil) {
194
+                                $record->pause($reason, $pausedUntil);
195
+                            });
196
+                        }),
197
+                    Tables\Actions\BulkAction::make('resume')
198
+                        ->label('Resume')
199
+                        ->icon('heroicon-m-play')
200
+                        ->color('success')
201
+                        ->databaseTransaction()
202
+                        ->successNotificationTitle('Adjustments resumed')
203
+                        ->failureNotificationTitle('Failed to resume adjustments')
204
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
205
+                            $isInvalid = $records->contains(fn (Adjustment $record) => ! $record->canBeResumed());
206
+
207
+                            if ($isInvalid) {
208
+                                Notification::make()
209
+                                    ->title('Resume failed')
210
+                                    ->body('Only adjustments that are currently paused can be resumed. Please adjust your selection and try again.')
211
+                                    ->persistent()
212
+                                    ->danger()
213
+                                    ->send();
214
+
215
+                                $action->cancel(true);
216
+                            }
217
+                        })
218
+                        ->deselectRecordsAfterCompletion()
219
+                        ->action(function (Collection $records) {
220
+                            $records->each(function (Adjustment $record) {
221
+                                $record->resume();
222
+                            });
223
+                        }),
224
+                    Tables\Actions\BulkAction::make('archive')
225
+                        ->label('Archive')
226
+                        ->icon('heroicon-m-archive-box')
227
+                        ->color('danger')
228
+                        ->form([
229
+                            Forms\Components\Textarea::make('status_reason')
230
+                                ->label('Reason for Archiving')
231
+                                ->maxLength(255),
232
+                        ])
233
+                        ->databaseTransaction()
234
+                        ->successNotificationTitle('Adjustments archived')
235
+                        ->failureNotificationTitle('Failed to archive adjustments')
236
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
237
+                            $isInvalid = $records->contains(fn (Adjustment $record) => ! $record->canBeArchived());
238
+
239
+                            if ($isInvalid) {
240
+                                Notification::make()
241
+                                    ->title('Archive failed')
242
+                                    ->body('Only adjustments that are currently active or paused can be archived. Please adjust your selection and try again.')
243
+                                    ->persistent()
244
+                                    ->danger()
245
+                                    ->send();
246
+
247
+                                $action->cancel(true);
248
+                            }
249
+                        })
250
+                        ->deselectRecordsAfterCompletion()
251
+                        ->action(function (Collection $records, array $data) {
252
+                            $reason = $data['status_reason'] ?? null;
253
+
254
+                            $records->each(function (Adjustment $record) use ($reason) {
255
+                                $record->archive($reason);
256
+                            });
257
+                        }),
258
+                ]),
124 259
             ]);
125 260
     }
126 261
 

+ 124
- 4
app/Models/Accounting/Adjustment.php Целия файл

@@ -34,6 +34,7 @@ class Adjustment extends Model
34 34
         'account_id',
35 35
         'name',
36 36
         'status',
37
+        'status_reason',
37 38
         'description',
38 39
         'category',
39 40
         'type',
@@ -43,6 +44,9 @@ class Adjustment extends Model
43 44
         'scope',
44 45
         'start_date',
45 46
         'end_date',
47
+        'paused_at',
48
+        'paused_until',
49
+        'archived_at',
46 50
         'created_by',
47 51
         'updated_by',
48 52
     ];
@@ -57,6 +61,9 @@ class Adjustment extends Model
57 61
         'scope' => AdjustmentScope::class,
58 62
         'start_date' => 'datetime',
59 63
         'end_date' => 'datetime',
64
+        'paused_at' => 'datetime',
65
+        'paused_until' => 'datetime',
66
+        'archived_at' => 'datetime',
60 67
     ];
61 68
 
62 69
     public function account(): BelongsTo
@@ -94,12 +101,37 @@ class Adjustment extends Model
94 101
         return $this->category->isDiscount() && $this->type->isPurchase();
95 102
     }
96 103
 
97
-    public function calculateStatus(): AdjustmentStatus
104
+    // Add these methods to your Adjustment model
105
+
106
+    /**
107
+     * Check if adjustment can be paused
108
+     */
109
+    public function canBePaused(): bool
98 110
     {
99
-        if ($this->status === AdjustmentStatus::Archived) {
100
-            return AdjustmentStatus::Archived;
101
-        }
111
+        return $this->status === AdjustmentStatus::Active;
112
+    }
113
+
114
+    /**
115
+     * Check if adjustment can be resumed
116
+     */
117
+    public function canBeResumed(): bool
118
+    {
119
+        return $this->status === AdjustmentStatus::Paused;
120
+    }
102 121
 
122
+    /**
123
+     * Check if adjustment can be archived
124
+     */
125
+    public function canBeArchived(): bool
126
+    {
127
+        return $this->status !== AdjustmentStatus::Archived;
128
+    }
129
+
130
+    /**
131
+     * Calculate the natural status of the adjustment based on dates
132
+     */
133
+    public function calculateNaturalStatus(): AdjustmentStatus
134
+    {
103 135
         if ($this->start_date?->isFuture()) {
104 136
             return AdjustmentStatus::Upcoming;
105 137
         }
@@ -111,6 +143,94 @@ class Adjustment extends Model
111 143
         return AdjustmentStatus::Active;
112 144
     }
113 145
 
146
+    /**
147
+     * Pause the adjustment
148
+     */
149
+    public function pause(?string $reason = null, ?\DateTime $untilDate = null): bool
150
+    {
151
+        if (! $this->canBePaused()) {
152
+            return false;
153
+        }
154
+
155
+        $this->paused_at = now();
156
+        $this->paused_until = $untilDate;
157
+        $this->status = AdjustmentStatus::Paused;
158
+        $this->status_reason = $reason;
159
+
160
+        return $this->save();
161
+    }
162
+
163
+    /**
164
+     * Resume the adjustment
165
+     */
166
+    public function resume(): bool
167
+    {
168
+        if (! $this->canBeResumed()) {
169
+            return false;
170
+        }
171
+
172
+        $this->paused_at = null;
173
+        $this->paused_until = null;
174
+        $this->status_reason = null;
175
+        $this->status = $this->calculateNaturalStatus();
176
+
177
+        return $this->save();
178
+    }
179
+
180
+    /**
181
+     * Archive the adjustment
182
+     */
183
+    public function archive(?string $reason = null): bool
184
+    {
185
+        if (! $this->canBeArchived()) {
186
+            return false;
187
+        }
188
+
189
+        $this->status = AdjustmentStatus::Archived;
190
+        $this->status_reason = $reason;
191
+
192
+        return $this->save();
193
+    }
194
+
195
+    /**
196
+     * Check if the adjustment should be automatically resumed
197
+     */
198
+    public function shouldAutoResume(): bool
199
+    {
200
+        return $this->status === AdjustmentStatus::Paused &&
201
+            $this->paused_until !== null &&
202
+            $this->paused_until->isPast();
203
+    }
204
+
205
+    /**
206
+     * Refresh the status based on current dates and conditions
207
+     */
208
+    public function refreshStatus(): bool
209
+    {
210
+        // Don't automatically change archived or paused status
211
+        if ($this->status === AdjustmentStatus::Archived ||
212
+            ($this->status === AdjustmentStatus::Paused && ! $this->shouldAutoResume())) {
213
+            return false;
214
+        }
215
+
216
+        // Check if a paused adjustment should be auto-resumed
217
+        if ($this->shouldAutoResume()) {
218
+            return $this->resume();
219
+        }
220
+
221
+        // Calculate natural status based on dates
222
+        $naturalStatus = $this->calculateNaturalStatus();
223
+
224
+        // Only update if the status would change
225
+        if ($this->status !== $naturalStatus) {
226
+            $this->status = $naturalStatus;
227
+
228
+            return $this->save();
229
+        }
230
+
231
+        return false;
232
+    }
233
+
114 234
     protected static function newFactory(): Factory
115 235
     {
116 236
         return AdjustmentFactory::new();

+ 26
- 6
app/Observers/AdjustmentObserver.php Целия файл

@@ -27,10 +27,6 @@ class AdjustmentObserver
27 27
                 $adjustment->account()->associate($account);
28 28
             }
29 29
         }
30
-
31
-        if ($adjustment->status !== AdjustmentStatus::Archived) {
32
-            $adjustment->status = $adjustment->calculateStatus();
33
-        }
34 30
     }
35 31
 
36 32
     public function updating(Adjustment $adjustment): void
@@ -41,9 +37,33 @@ class AdjustmentObserver
41 37
                 'description' => $adjustment->description,
42 38
             ]);
43 39
         }
40
+    }
41
+
42
+    /**
43
+     * Handle the Adjustment "saving" event.
44
+     */
45
+    public function saving(Adjustment $adjustment): void
46
+    {
47
+        // Handle dates changes affecting status
48
+        // Only if the status isn't being explicitly changed and not in a manual state
49
+        if ($adjustment->isDirty(['start_date', 'end_date']) &&
50
+            ! $adjustment->isDirty('status') &&
51
+            ! in_array($adjustment->status, [AdjustmentStatus::Archived, AdjustmentStatus::Paused])) {
52
+
53
+            $adjustment->status = $adjustment->calculateNaturalStatus();
54
+        }
55
+
56
+        // Handle auto-resume for paused adjustments with a paused_until date
57
+        if ($adjustment->shouldAutoResume() && ! $adjustment->isDirty('status')) {
58
+            $adjustment->status = $adjustment->calculateNaturalStatus();
59
+            $adjustment->paused_at = null;
60
+            $adjustment->paused_until = null;
61
+            $adjustment->status_reason = null;
62
+        }
44 63
 
45
-        if ($adjustment->status !== AdjustmentStatus::Archived) {
46
-            $adjustment->status = $adjustment->calculateStatus();
64
+        // Ensure consistency between paused status and paused_at field
65
+        if ($adjustment->status === AdjustmentStatus::Paused && ! $adjustment->paused_at) {
66
+            $adjustment->paused_at = now();
47 67
         }
48 68
     }
49 69
 }

+ 4
- 0
database/migrations/2024_11_14_230753_create_adjustments_table.php Целия файл

@@ -17,6 +17,7 @@ return new class extends Migration
17 17
             $table->foreignId('account_id')->nullable()->constrained('accounts')->nullOnDelete();
18 18
             $table->string('name')->nullable();
19 19
             $table->string('status')->default('active');
20
+            $table->text('status_reason')->nullable();
20 21
             $table->text('description')->nullable();
21 22
             $table->string('category')->default('tax');
22 23
             $table->string('type')->default('sales');
@@ -26,6 +27,9 @@ return new class extends Migration
26 27
             $table->string('scope')->nullable();
27 28
             $table->dateTime('start_date')->nullable();
28 29
             $table->dateTime('end_date')->nullable();
30
+            $table->timestamp('paused_at')->nullable();
31
+            $table->timestamp('paused_until')->nullable();
32
+            $table->timestamp('archived_at')->nullable();
29 33
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
30 34
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
31 35
             $table->timestamps();

Loading…
Отказ
Запис