Andrew Wallo 9 月之前
父節點
當前提交
7ae44f39ae

+ 2
- 1
app/Enums/Accounting/InvoiceStatus.php 查看文件

10
     case Draft = 'draft';
10
     case Draft = 'draft';
11
     case Unsent = 'unsent';
11
     case Unsent = 'unsent';
12
     case Sent = 'sent';
12
     case Sent = 'sent';
13
+    case Viewed = 'viewed';
13
 
14
 
14
     case Partial = 'partial';
15
     case Partial = 'partial';
15
 
16
 
30
     {
31
     {
31
         return match ($this) {
32
         return match ($this) {
32
             self::Draft, self::Unsent, self::Void => 'gray',
33
             self::Draft, self::Unsent, self::Void => 'gray',
33
-            self::Sent => 'primary',
34
+            self::Sent, self::Viewed => 'primary',
34
             self::Partial => 'warning',
35
             self::Partial => 'warning',
35
             self::Paid, self::Overpaid => 'success',
36
             self::Paid, self::Overpaid => 'success',
36
             self::Overdue => 'danger',
37
             self::Overdue => 'danger',

+ 2
- 2
app/Filament/Company/Resources/Sales/EstimateResource.php 查看文件

449
                         ->successNotificationTitle('Estimates Accepted')
449
                         ->successNotificationTitle('Estimates Accepted')
450
                         ->failureNotificationTitle('Failed to Mark Estimates as Accepted')
450
                         ->failureNotificationTitle('Failed to Mark Estimates as Accepted')
451
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
451
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
452
-                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->isAccepted());
452
+                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->wasAccepted());
453
 
453
 
454
                             if ($doesntContainSent) {
454
                             if ($doesntContainSent) {
455
                                 Notification::make()
455
                                 Notification::make()
480
                         ->successNotificationTitle('Estimates Declined')
480
                         ->successNotificationTitle('Estimates Declined')
481
                         ->failureNotificationTitle('Failed to Mark Estimates as Declined')
481
                         ->failureNotificationTitle('Failed to Mark Estimates as Declined')
482
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
482
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
483
-                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->isDeclined());
483
+                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->wasDeclined());
484
 
484
 
485
                             if ($doesntContainSent) {
485
                             if ($doesntContainSent) {
486
                                 Notification::make()
486
                                 Notification::make()

+ 16
- 1
app/Models/Accounting/Bill.php 查看文件

32
 use Illuminate\Database\Eloquent\Relations\MorphOne;
32
 use Illuminate\Database\Eloquent\Relations\MorphOne;
33
 use Illuminate\Support\Carbon;
33
 use Illuminate\Support\Carbon;
34
 
34
 
35
-#[ObservedBy(BillObserver::class)]
36
 #[CollectedBy(DocumentCollection::class)]
35
 #[CollectedBy(DocumentCollection::class)]
36
+#[ObservedBy(BillObserver::class)]
37
 class Bill extends Model
37
 class Bill extends Model
38
 {
38
 {
39
     use Blamable;
39
     use Blamable;
129
         });
129
         });
130
     }
130
     }
131
 
131
 
132
+    public function wasInitialized(): bool
133
+    {
134
+        return $this->hasInitialTransaction();
135
+    }
136
+
137
+    public function isPaid(): bool
138
+    {
139
+        return $this->paid_at !== null;
140
+    }
141
+
132
     public function canBeOverdue(): bool
142
     public function canBeOverdue(): bool
133
     {
143
     {
134
         return in_array($this->status, BillStatus::canBeOverdue());
144
         return in_array($this->status, BillStatus::canBeOverdue());
142
         ]) && $this->currency_code === CurrencyAccessor::getDefaultCurrency();
152
         ]) && $this->currency_code === CurrencyAccessor::getDefaultCurrency();
143
     }
153
     }
144
 
154
 
155
+    public function hasLineItems(): bool
156
+    {
157
+        return $this->lineItems()->exists();
158
+    }
159
+
145
     public function hasPayments(): bool
160
     public function hasPayments(): bool
146
     {
161
     {
147
         return $this->payments->isNotEmpty();
162
         return $this->payments->isNotEmpty();

+ 59
- 9
app/Models/Accounting/Estimate.php 查看文件

119
         return $this->status === EstimateStatus::Draft;
119
         return $this->status === EstimateStatus::Draft;
120
     }
120
     }
121
 
121
 
122
-    public function isApproved(): bool
122
+    public function wasApproved(): bool
123
     {
123
     {
124
         return $this->approved_at !== null;
124
         return $this->approved_at !== null;
125
     }
125
     }
126
 
126
 
127
-    public function isAccepted(): bool
127
+    public function wasAccepted(): bool
128
     {
128
     {
129
         return $this->accepted_at !== null;
129
         return $this->accepted_at !== null;
130
     }
130
     }
131
 
131
 
132
-    public function isDeclined(): bool
132
+    public function wasDeclined(): bool
133
     {
133
     {
134
         return $this->declined_at !== null;
134
         return $this->declined_at !== null;
135
     }
135
     }
136
 
136
 
137
-    public function isSent(): bool
137
+    public function wasConverted(): bool
138
+    {
139
+        return $this->converted_at !== null;
140
+    }
141
+
142
+    public function hasBeenSent(): bool
138
     {
143
     {
139
         return $this->last_sent_at !== null;
144
         return $this->last_sent_at !== null;
140
     }
145
     }
141
 
146
 
147
+    public function hasBeenViewed(): bool
148
+    {
149
+        return $this->last_viewed_at !== null;
150
+    }
151
+
142
     public function canBeExpired(): bool
152
     public function canBeExpired(): bool
143
     {
153
     {
144
         return ! in_array($this->status, [
154
         return ! in_array($this->status, [
149
         ]);
159
         ]);
150
     }
160
     }
151
 
161
 
162
+    public function canBeApproved(): bool
163
+    {
164
+        return $this->isDraft() && ! $this->wasApproved();
165
+    }
166
+
167
+    public function canBeConverted(): bool
168
+    {
169
+        return $this->wasAccepted() && ! $this->wasConverted();
170
+    }
171
+
172
+    public function canBeMarkedAsDeclined(): bool
173
+    {
174
+        return $this->hasBeenSent() && ! $this->wasDeclined();
175
+    }
176
+
177
+    public function canBeMarkedAsSent(): bool
178
+    {
179
+        return ! $this->hasBeenSent();
180
+    }
181
+
182
+    public function canBeMarkedAsAccepted(): bool
183
+    {
184
+        return $this->hasBeenSent() && ! $this->wasAccepted();
185
+    }
186
+
187
+    public function hasLineItems(): bool
188
+    {
189
+        return $this->lineItems()->exists();
190
+    }
191
+
152
     public function scopeActive(Builder $query): Builder
192
     public function scopeActive(Builder $query): Builder
153
     {
193
     {
154
         return $query->whereIn('status', [
194
         return $query->whereIn('status', [
212
             ->label('Approve')
252
             ->label('Approve')
213
             ->icon('heroicon-o-check-circle')
253
             ->icon('heroicon-o-check-circle')
214
             ->visible(function (self $record) {
254
             ->visible(function (self $record) {
215
-                return $record->isDraft();
255
+                return $record->canBeApproved();
216
             })
256
             })
217
             ->databaseTransaction()
257
             ->databaseTransaction()
218
             ->successNotificationTitle('Estimate Approved')
258
             ->successNotificationTitle('Estimate Approved')
229
             ->label('Mark as Sent')
269
             ->label('Mark as Sent')
230
             ->icon('heroicon-o-paper-airplane')
270
             ->icon('heroicon-o-paper-airplane')
231
             ->visible(static function (self $record) {
271
             ->visible(static function (self $record) {
232
-                return ! $record->isSent();
272
+                return $record->canBeMarkedAsSent();
233
             })
273
             })
234
             ->successNotificationTitle('Estimate Sent')
274
             ->successNotificationTitle('Estimate Sent')
235
             ->action(function (self $record, MountableAction $action) {
275
             ->action(function (self $record, MountableAction $action) {
249
         ]);
289
         ]);
250
     }
290
     }
251
 
291
 
292
+    public function markAsViewed(?Carbon $viewedAt = null): void
293
+    {
294
+        $viewedAt ??= now();
295
+
296
+        $this->update([
297
+            'status' => EstimateStatus::Viewed,
298
+            'last_viewed_at' => $viewedAt,
299
+        ]);
300
+    }
301
+
252
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
302
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
253
     {
303
     {
254
         return $action::make()
304
         return $action::make()
289
             ->label('Mark as Accepted')
339
             ->label('Mark as Accepted')
290
             ->icon('heroicon-o-check-badge')
340
             ->icon('heroicon-o-check-badge')
291
             ->visible(static function (self $record) {
341
             ->visible(static function (self $record) {
292
-                return $record->isSent() && ! $record->isAccepted();
342
+                return $record->canBeMarkedAsAccepted();
293
             })
343
             })
294
             ->databaseTransaction()
344
             ->databaseTransaction()
295
             ->successNotificationTitle('Estimate Accepted')
345
             ->successNotificationTitle('Estimate Accepted')
316
             ->label('Mark as Declined')
366
             ->label('Mark as Declined')
317
             ->icon('heroicon-o-x-circle')
367
             ->icon('heroicon-o-x-circle')
318
             ->visible(static function (self $record) {
368
             ->visible(static function (self $record) {
319
-                return $record->isSent() && ! $record->isDeclined();
369
+                return $record->canBeMarkedAsDeclined();
320
             })
370
             })
321
             ->color('danger')
371
             ->color('danger')
322
             ->requiresConfirmation()
372
             ->requiresConfirmation()
345
             ->label('Convert to Invoice')
395
             ->label('Convert to Invoice')
346
             ->icon('heroicon-o-arrow-right-on-rectangle')
396
             ->icon('heroicon-o-arrow-right-on-rectangle')
347
             ->visible(static function (self $record) {
397
             ->visible(static function (self $record) {
348
-                return $record->status === EstimateStatus::Accepted && ! $record->invoice;
398
+                return $record->canBeConverted();
349
             })
399
             })
350
             ->databaseTransaction()
400
             ->databaseTransaction()
351
             ->successNotificationTitle('Estimate Converted to Invoice')
401
             ->successNotificationTitle('Estimate Converted to Invoice')

+ 73
- 22
app/Models/Accounting/Invoice.php 查看文件

58
         'approved_at',
58
         'approved_at',
59
         'paid_at',
59
         'paid_at',
60
         'last_sent_at',
60
         'last_sent_at',
61
+        'last_viewed_at',
61
         'status',
62
         'status',
62
         'currency_code',
63
         'currency_code',
63
         'discount_method',
64
         'discount_method',
80
         'approved_at' => 'datetime',
81
         'approved_at' => 'datetime',
81
         'paid_at' => 'datetime',
82
         'paid_at' => 'datetime',
82
         'last_sent_at' => 'datetime',
83
         'last_sent_at' => 'datetime',
84
+        'last_viewed_at' => 'datetime',
83
         'status' => InvoiceStatus::class,
85
         'status' => InvoiceStatus::class,
84
         'discount_method' => DocumentDiscountMethod::class,
86
         'discount_method' => DocumentDiscountMethod::class,
85
         'discount_computation' => AdjustmentComputation::class,
87
         'discount_computation' => AdjustmentComputation::class,
160
         return $this->status === InvoiceStatus::Draft;
162
         return $this->status === InvoiceStatus::Draft;
161
     }
163
     }
162
 
164
 
165
+    public function wasApproved(): bool
166
+    {
167
+        return $this->approved_at !== null;
168
+    }
169
+
170
+    public function isPaid(): bool
171
+    {
172
+        return $this->paid_at !== null;
173
+    }
174
+
175
+    public function hasBeenSent(): bool
176
+    {
177
+        return $this->last_sent_at !== null;
178
+    }
179
+
180
+    public function hasBeenViewed(): bool
181
+    {
182
+        return $this->last_viewed_at !== null;
183
+    }
184
+
163
     public function canRecordPayment(): bool
185
     public function canRecordPayment(): bool
164
     {
186
     {
165
         return ! in_array($this->status, [
187
         return ! in_array($this->status, [
184
         return in_array($this->status, InvoiceStatus::canBeOverdue());
206
         return in_array($this->status, InvoiceStatus::canBeOverdue());
185
     }
207
     }
186
 
208
 
209
+    public function canBeApproved(): bool
210
+    {
211
+        return $this->isDraft() && ! $this->wasApproved();
212
+    }
213
+
214
+    public function canBeMarkedAsSent(): bool
215
+    {
216
+        return ! $this->hasBeenSent();
217
+    }
218
+
219
+    public function hasLineItems(): bool
220
+    {
221
+        return $this->lineItems()->exists();
222
+    }
223
+
187
     public function hasPayments(): bool
224
     public function hasPayments(): bool
188
     {
225
     {
189
-        return $this->payments->isNotEmpty();
226
+        return $this->payments()->exists();
190
     }
227
     }
191
 
228
 
192
     public static function getNextDocumentNumber(?Company $company = null): string
229
     public static function getNextDocumentNumber(?Company $company = null): string
398
             ->label('Approve')
435
             ->label('Approve')
399
             ->icon('heroicon-o-check-circle')
436
             ->icon('heroicon-o-check-circle')
400
             ->visible(function (self $record) {
437
             ->visible(function (self $record) {
401
-                return $record->isDraft();
438
+                return $record->canBeApproved();
402
             })
439
             })
403
             ->databaseTransaction()
440
             ->databaseTransaction()
404
             ->successNotificationTitle('Invoice Approved')
441
             ->successNotificationTitle('Invoice Approved')
415
             ->label('Mark as Sent')
452
             ->label('Mark as Sent')
416
             ->icon('heroicon-o-paper-airplane')
453
             ->icon('heroicon-o-paper-airplane')
417
             ->visible(static function (self $record) {
454
             ->visible(static function (self $record) {
418
-                return ! $record->last_sent_at;
455
+                return $record->canBeMarkedAsSent();
419
             })
456
             })
420
             ->successNotificationTitle('Invoice Sent')
457
             ->successNotificationTitle('Invoice Sent')
421
             ->action(function (self $record, MountableAction $action) {
458
             ->action(function (self $record, MountableAction $action) {
435
         ]);
472
         ]);
436
     }
473
     }
437
 
474
 
475
+    public function markAsViewed(?Carbon $viewedAt = null): void
476
+    {
477
+        $viewedAt ??= now();
478
+
479
+        $this->update([
480
+            'status' => InvoiceStatus::Viewed,
481
+            'last_viewed_at' => $viewedAt,
482
+        ]);
483
+    }
484
+
438
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
485
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
439
     {
486
     {
440
         return $action::make()
487
         return $action::make()
462
             })
509
             })
463
             ->databaseTransaction()
510
             ->databaseTransaction()
464
             ->after(function (self $original, self $replica) {
511
             ->after(function (self $original, self $replica) {
465
-                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
466
-                    $replicaLineItem = $lineItem->replicate([
467
-                        'documentable_id',
468
-                        'documentable_type',
469
-                        'subtotal',
470
-                        'total',
471
-                        'created_by',
472
-                        'updated_by',
473
-                        'created_at',
474
-                        'updated_at',
475
-                    ]);
476
-
477
-                    $replicaLineItem->documentable_id = $replica->id;
478
-                    $replicaLineItem->documentable_type = $replica->getMorphClass();
479
-
480
-                    $replicaLineItem->save();
481
-
482
-                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
483
-                });
512
+                $original->replicateLineItems($replica);
484
             })
513
             })
485
             ->successRedirectUrl(static function (self $replica) {
514
             ->successRedirectUrl(static function (self $replica) {
486
                 return InvoiceResource::getUrl('edit', ['record' => $replica]);
515
                 return InvoiceResource::getUrl('edit', ['record' => $replica]);
487
             });
516
             });
488
     }
517
     }
518
+
519
+    public function replicateLineItems(Model $target): void
520
+    {
521
+        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
522
+            $replica = $lineItem->replicate([
523
+                'documentable_id',
524
+                'documentable_type',
525
+                'subtotal',
526
+                'total',
527
+                'created_by',
528
+                'updated_by',
529
+                'created_at',
530
+                'updated_at',
531
+            ]);
532
+
533
+            $replica->documentable_id = $target->id;
534
+            $replica->documentable_type = $target->getMorphClass();
535
+            $replica->save();
536
+
537
+            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
538
+        });
539
+    }
489
 }
540
 }

+ 1
- 2
app/Policies/EstimatePolicy.php 查看文件

2
 
2
 
3
 namespace App\Policies;
3
 namespace App\Policies;
4
 
4
 
5
-use App\Enums\Accounting\EstimateStatus;
6
 use App\Models\Accounting\Estimate;
5
 use App\Models\Accounting\Estimate;
7
 use App\Models\User;
6
 use App\Models\User;
8
 
7
 
37
      */
36
      */
38
     public function update(User $user, Estimate $estimate): bool
37
     public function update(User $user, Estimate $estimate): bool
39
     {
38
     {
40
-        if ($estimate->status === EstimateStatus::Converted) {
39
+        if ($estimate->wasConverted()) {
41
             return false;
40
             return false;
42
         }
41
         }
43
 
42
 

+ 6
- 6
composer.lock 查看文件

497
         },
497
         },
498
         {
498
         {
499
             "name": "aws/aws-sdk-php",
499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.4",
500
+            "version": "3.336.6",
501
             "source": {
501
             "source": {
502
                 "type": "git",
502
                 "type": "git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc"
504
+                "reference": "0a99dab427f0a1c082775301141aeac3558691ad"
505
             },
505
             },
506
             "dist": {
506
             "dist": {
507
                 "type": "zip",
507
                 "type": "zip",
508
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc",
509
-                "reference": "5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc",
508
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0a99dab427f0a1c082775301141aeac3558691ad",
509
+                "reference": "0a99dab427f0a1c082775301141aeac3558691ad",
510
                 "shasum": ""
510
                 "shasum": ""
511
             },
511
             },
512
             "require": {
512
             "require": {
589
             "support": {
589
             "support": {
590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
592
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.4"
592
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.6"
593
             },
593
             },
594
-            "time": "2024-12-26T19:13:21+00:00"
594
+            "time": "2024-12-28T04:16:13+00:00"
595
         },
595
         },
596
         {
596
         {
597
             "name": "aws/aws-sdk-php-laravel",
597
             "name": "aws/aws-sdk-php-laravel",

+ 94
- 43
database/factories/Accounting/BillFactory.php 查看文件

29
      */
29
      */
30
     public function definition(): array
30
     public function definition(): array
31
     {
31
     {
32
-        // 50% chance of being a future bill
33
         $isFutureBill = $this->faker->boolean();
32
         $isFutureBill = $this->faker->boolean();
34
 
33
 
35
         if ($isFutureBill) {
34
         if ($isFutureBill) {
36
-            // For future bills, date is recent and due date is in near future
37
             $billDate = $this->faker->dateTimeBetween('-10 days', '+10 days');
35
             $billDate = $this->faker->dateTimeBetween('-10 days', '+10 days');
38
         } else {
36
         } else {
39
-            // For past bills, both date and due date are in the past
40
             $billDate = $this->faker->dateTimeBetween('-1 year', '-30 days');
37
             $billDate = $this->faker->dateTimeBetween('-1 year', '-30 days');
41
         }
38
         }
42
 
39
 
57
         ];
54
         ];
58
     }
55
     }
59
 
56
 
60
-    public function withLineItems(int $count = 3): self
57
+    public function withLineItems(int $count = 3): static
61
     {
58
     {
62
-        return $this->has(DocumentLineItem::factory()->forBill()->count($count), 'lineItems');
59
+        return $this->afterCreating(function (Bill $bill) use ($count) {
60
+            DocumentLineItem::factory()
61
+                ->count($count)
62
+                ->forBill($bill)
63
+                ->create();
64
+
65
+            $this->recalculateTotals($bill);
66
+        });
63
     }
67
     }
64
 
68
 
65
     public function initialized(): static
69
     public function initialized(): static
66
     {
70
     {
67
         return $this->afterCreating(function (Bill $bill) {
71
         return $this->afterCreating(function (Bill $bill) {
68
-            if ($bill->hasInitialTransaction()) {
72
+            $this->ensureLineItems($bill);
73
+
74
+            if ($bill->wasInitialized()) {
69
                 return;
75
                 return;
70
             }
76
             }
71
 
77
 
72
-            $this->recalculateTotals($bill);
73
-
74
-            $postedAt = Carbon::parse($bill->date)->addHours($this->faker->numberBetween(1, 24));
78
+            $postedAt = Carbon::parse($bill->date)
79
+                ->addHours($this->faker->numberBetween(1, 24));
75
 
80
 
76
             $bill->createInitialTransaction($postedAt);
81
             $bill->createInitialTransaction($postedAt);
77
         });
82
         });
78
     }
83
     }
79
 
84
 
80
-    public function withPayments(?int $min = 1, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
85
+    public function partial(int $maxPayments = 4): static
81
     {
86
     {
82
-        return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
83
-            if (! $bill->hasInitialTransaction()) {
84
-                $this->recalculateTotals($bill);
87
+        return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
88
+            $this->ensureInitialized($bill);
85
 
89
 
86
-                $postedAt = Carbon::parse($bill->date)->addHours($this->faker->numberBetween(1, 24));
90
+            $this->withPayments(max: $maxPayments, billStatus: BillStatus::Partial)
91
+                ->callAfterCreating(collect([$bill]));
92
+        });
93
+    }
87
 
94
 
88
-                $bill->createInitialTransaction($postedAt);
89
-            }
95
+    public function paid(int $maxPayments = 4): static
96
+    {
97
+        return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
98
+            $this->ensureInitialized($bill);
99
+
100
+            $this->withPayments(max: $maxPayments)
101
+                ->callAfterCreating(collect([$bill]));
102
+        });
103
+    }
104
+
105
+    public function overdue(): static
106
+    {
107
+        return $this
108
+            ->state([
109
+                'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
110
+            ])
111
+            ->afterCreating(function (Bill $bill) {
112
+                $this->ensureInitialized($bill);
113
+            });
114
+    }
115
+
116
+    public function withPayments(?int $min = null, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
117
+    {
118
+        $min ??= 1;
119
+
120
+        return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
121
+            $this->ensureInitialized($bill);
90
 
122
 
91
             $bill->refresh();
123
             $bill->refresh();
92
 
124
 
93
-            $totalAmountDue = $bill->getRawOriginal('amount_due');
125
+            $amountDue = $bill->getRawOriginal('amount_due');
94
 
126
 
95
-            if ($billStatus === BillStatus::Partial) {
96
-                $totalAmountDue = (int) floor($totalAmountDue * 0.5);
97
-            }
127
+            $totalAmountDue = match ($billStatus) {
128
+                BillStatus::Partial => (int) floor($amountDue * 0.5),
129
+                default => $amountDue,
130
+            };
98
 
131
 
99
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
132
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
100
                 return;
133
                 return;
129
                 $remainingAmount -= $amount;
162
                 $remainingAmount -= $amount;
130
             }
163
             }
131
 
164
 
132
-            if ($billStatus === BillStatus::Paid) {
133
-                $latestPaymentDate = max($paymentDates);
134
-                $bill->updateQuietly([
135
-                    'status' => $billStatus,
136
-                    'paid_at' => $latestPaymentDate,
137
-                ]);
165
+            if ($billStatus !== BillStatus::Paid) {
166
+                return;
138
             }
167
             }
168
+
169
+            $latestPaymentDate = max($paymentDates);
170
+            $bill->updateQuietly([
171
+                'status' => $billStatus,
172
+                'paid_at' => $latestPaymentDate,
173
+            ]);
139
         });
174
         });
140
     }
175
     }
141
 
176
 
142
     public function configure(): static
177
     public function configure(): static
143
     {
178
     {
144
         return $this->afterCreating(function (Bill $bill) {
179
         return $this->afterCreating(function (Bill $bill) {
180
+            $this->ensureInitialized($bill);
181
+
145
             $paddedId = str_pad((string) $bill->id, 5, '0', STR_PAD_LEFT);
182
             $paddedId = str_pad((string) $bill->id, 5, '0', STR_PAD_LEFT);
146
 
183
 
147
             $bill->updateQuietly([
184
             $bill->updateQuietly([
149
                 'order_number' => "PO-{$paddedId}",
186
                 'order_number' => "PO-{$paddedId}",
150
             ]);
187
             ]);
151
 
188
 
152
-            $this->recalculateTotals($bill);
153
-
154
-            // Check for overdue status
155
-            if ($bill->due_date < today() && $bill->status !== BillStatus::Paid) {
189
+            if ($bill->wasInitialized() && $bill->is_currently_overdue) {
156
                 $bill->updateQuietly([
190
                 $bill->updateQuietly([
157
                     'status' => BillStatus::Overdue,
191
                     'status' => BillStatus::Overdue,
158
                 ]);
192
                 ]);
160
         });
194
         });
161
     }
195
     }
162
 
196
 
197
+    protected function ensureLineItems(Bill $bill): void
198
+    {
199
+        if (! $bill->hasLineItems()) {
200
+            $this->withLineItems()->callAfterCreating(collect([$bill]));
201
+        }
202
+    }
203
+
204
+    protected function ensureInitialized(Bill $bill): void
205
+    {
206
+        if (! $bill->wasInitialized()) {
207
+            $this->initialized()->callAfterCreating(collect([$bill]));
208
+        }
209
+    }
210
+
163
     protected function recalculateTotals(Bill $bill): void
211
     protected function recalculateTotals(Bill $bill): void
164
     {
212
     {
165
-        if ($bill->lineItems()->exists()) {
166
-            $bill->refresh();
167
-            $subtotal = $bill->lineItems()->sum('subtotal') / 100;
168
-            $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
169
-            $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
170
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
171
-
172
-            $bill->update([
173
-                'subtotal' => $subtotal,
174
-                'tax_total' => $taxTotal,
175
-                'discount_total' => $discountTotal,
176
-                'total' => $grandTotal,
177
-            ]);
213
+        $bill->refresh();
214
+
215
+        if (! $bill->hasLineItems()) {
216
+            return;
178
         }
217
         }
218
+
219
+        $subtotal = $bill->lineItems()->sum('subtotal') / 100;
220
+        $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
221
+        $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
222
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
223
+
224
+        $bill->update([
225
+            'subtotal' => $subtotal,
226
+            'tax_total' => $taxTotal,
227
+            'discount_total' => $discountTotal,
228
+            'total' => $grandTotal,
229
+        ]);
179
     }
230
     }
180
 }
231
 }

+ 96
- 84
database/factories/Accounting/DocumentLineItemFactory.php 查看文件

2
 
2
 
3
 namespace Database\Factories\Accounting;
3
 namespace Database\Factories\Accounting;
4
 
4
 
5
+use App\Models\Accounting\Bill;
5
 use App\Models\Accounting\DocumentLineItem;
6
 use App\Models\Accounting\DocumentLineItem;
7
+use App\Models\Accounting\Estimate;
8
+use App\Models\Accounting\Invoice;
6
 use App\Models\Common\Offering;
9
 use App\Models\Common\Offering;
7
 use Illuminate\Database\Eloquent\Factories\Factory;
10
 use Illuminate\Database\Eloquent\Factories\Factory;
8
 
11
 
34
         ];
37
         ];
35
     }
38
     }
36
 
39
 
37
-    public function forInvoice(): static
40
+    public function forInvoice(Invoice $invoice): static
38
     {
41
     {
39
-        return $this->state(function (array $attributes) {
40
-            $offering = Offering::where('sellable', true)
41
-                ->inRandomOrder()
42
-                ->first();
43
-
44
-            return [
45
-                'offering_id' => $offering->id,
46
-                'unit_price' => $offering->price,
47
-            ];
48
-        })->afterCreating(function (DocumentLineItem $lineItem) {
49
-            $offering = $lineItem->offering;
50
-
51
-            if ($offering) {
52
-                $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
53
-                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
54
-            }
55
-
56
-            $lineItem->refresh();
57
-
58
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
59
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
60
-
61
-            $lineItem->updateQuietly([
62
-                'tax_total' => $taxTotal,
63
-                'discount_total' => $discountTotal,
64
-            ]);
65
-        });
42
+        return $this
43
+            ->for($invoice, 'documentable')
44
+            ->state(function (array $attributes) {
45
+                $offering = Offering::where('sellable', true)
46
+                    ->inRandomOrder()
47
+                    ->first();
48
+
49
+                return [
50
+                    'offering_id' => $offering->id,
51
+                    'unit_price' => $offering->price,
52
+                ];
53
+            })
54
+            ->afterCreating(function (DocumentLineItem $lineItem) {
55
+                $offering = $lineItem->offering;
56
+
57
+                if ($offering) {
58
+                    $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
59
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
60
+                }
61
+
62
+                $lineItem->refresh();
63
+
64
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
65
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
66
+
67
+                $lineItem->updateQuietly([
68
+                    'tax_total' => $taxTotal,
69
+                    'discount_total' => $discountTotal,
70
+                ]);
71
+            });
66
     }
72
     }
67
 
73
 
68
-    public function forEstimate(): static
74
+    public function forEstimate(Estimate $estimate): static
69
     {
75
     {
70
-        return $this->state(function (array $attributes) {
71
-            $offering = Offering::where('sellable', true)
72
-                ->inRandomOrder()
73
-                ->first();
74
-
75
-            return [
76
-                'offering_id' => $offering->id,
77
-                'unit_price' => $offering->price,
78
-            ];
79
-        })->afterCreating(function (DocumentLineItem $lineItem) {
80
-            $offering = $lineItem->offering;
81
-
82
-            if ($offering) {
83
-                $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
84
-                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
85
-            }
86
-
87
-            $lineItem->refresh();
88
-
89
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
90
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
91
-
92
-            $lineItem->updateQuietly([
93
-                'tax_total' => $taxTotal,
94
-                'discount_total' => $discountTotal,
95
-            ]);
96
-        });
76
+        return $this
77
+            ->for($estimate, 'documentable')
78
+            ->state(function (array $attributes) {
79
+                $offering = Offering::where('sellable', true)
80
+                    ->inRandomOrder()
81
+                    ->first();
82
+
83
+                return [
84
+                    'offering_id' => $offering->id,
85
+                    'unit_price' => $offering->price,
86
+                ];
87
+            })
88
+            ->afterCreating(function (DocumentLineItem $lineItem) {
89
+                $offering = $lineItem->offering;
90
+
91
+                if ($offering) {
92
+                    $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
93
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
94
+                }
95
+
96
+                $lineItem->refresh();
97
+
98
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
99
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
100
+
101
+                $lineItem->updateQuietly([
102
+                    'tax_total' => $taxTotal,
103
+                    'discount_total' => $discountTotal,
104
+                ]);
105
+            });
97
     }
106
     }
98
 
107
 
99
-    public function forBill(): static
108
+    public function forBill(Bill $bill): static
100
     {
109
     {
101
-        return $this->state(function (array $attributes) {
102
-            $offering = Offering::where('purchasable', true)
103
-                ->inRandomOrder()
104
-                ->first();
105
-
106
-            return [
107
-                'offering_id' => $offering->id,
108
-                'unit_price' => $offering->price,
109
-            ];
110
-        })->afterCreating(function (DocumentLineItem $lineItem) {
111
-            $offering = $lineItem->offering;
112
-
113
-            if ($offering) {
114
-                $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
115
-                $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
116
-            }
117
-
118
-            $lineItem->refresh();
119
-
120
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
121
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
122
-
123
-            $lineItem->updateQuietly([
124
-                'tax_total' => $taxTotal,
125
-                'discount_total' => $discountTotal,
126
-            ]);
127
-        });
110
+        return $this
111
+            ->for($bill, 'documentable')
112
+            ->state(function (array $attributes) {
113
+                $offering = Offering::where('purchasable', true)
114
+                    ->inRandomOrder()
115
+                    ->first();
116
+
117
+                return [
118
+                    'offering_id' => $offering->id,
119
+                    'unit_price' => $offering->price,
120
+                ];
121
+            })
122
+            ->afterCreating(function (DocumentLineItem $lineItem) {
123
+                $offering = $lineItem->offering;
124
+
125
+                if ($offering) {
126
+                    $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
127
+                    $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
128
+                }
129
+
130
+                $lineItem->refresh();
131
+
132
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
133
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
134
+
135
+                $lineItem->updateQuietly([
136
+                    'tax_total' => $taxTotal,
137
+                    'discount_total' => $discountTotal,
138
+                ]);
139
+            });
128
     }
140
     }
129
 }
141
 }

+ 87
- 42
database/factories/Accounting/EstimateFactory.php 查看文件

46
         ];
46
         ];
47
     }
47
     }
48
 
48
 
49
-    public function withLineItems(int $count = 3): self
49
+    public function withLineItems(int $count = 3): static
50
     {
50
     {
51
-        return $this->has(DocumentLineItem::factory()->forEstimate()->count($count), 'lineItems');
51
+        return $this->afterCreating(function (Estimate $estimate) use ($count) {
52
+            DocumentLineItem::factory()
53
+                ->count($count)
54
+                ->forEstimate($estimate)
55
+                ->create();
56
+
57
+            $this->recalculateTotals($estimate);
58
+        });
52
     }
59
     }
53
 
60
 
54
     public function approved(): static
61
     public function approved(): static
55
     {
62
     {
56
         return $this->afterCreating(function (Estimate $estimate) {
63
         return $this->afterCreating(function (Estimate $estimate) {
57
-            if (! $estimate->isDraft()) {
64
+            $this->ensureLineItems($estimate);
65
+
66
+            if (! $estimate->canBeApproved()) {
58
                 return;
67
                 return;
59
             }
68
             }
60
 
69
 
61
-            $this->recalculateTotals($estimate);
62
-
63
-            $approvedAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
70
+            $approvedAt = Carbon::parse($estimate->date)
71
+                ->addHours($this->faker->numberBetween(1, 24));
64
 
72
 
65
             $estimate->approveDraft($approvedAt);
73
             $estimate->approveDraft($approvedAt);
66
         });
74
         });
69
     public function accepted(): static
77
     public function accepted(): static
70
     {
78
     {
71
         return $this->afterCreating(function (Estimate $estimate) {
79
         return $this->afterCreating(function (Estimate $estimate) {
72
-            if (! $estimate->isApproved()) {
73
-                $this->approved()->create();
74
-            }
80
+            $this->ensureSent($estimate);
75
 
81
 
76
-            $acceptedAt = Carbon::parse($estimate->approved_at)
82
+            $acceptedAt = Carbon::parse($estimate->last_sent_at)
77
                 ->addDays($this->faker->numberBetween(1, 7));
83
                 ->addDays($this->faker->numberBetween(1, 7));
78
 
84
 
79
             $estimate->markAsAccepted($acceptedAt);
85
             $estimate->markAsAccepted($acceptedAt);
83
     public function converted(): static
89
     public function converted(): static
84
     {
90
     {
85
         return $this->afterCreating(function (Estimate $estimate) {
91
         return $this->afterCreating(function (Estimate $estimate) {
86
-            if (! $estimate->isAccepted()) {
87
-                $this->accepted()->create();
92
+            if (! $estimate->wasAccepted()) {
93
+                $this->accepted()->callAfterCreating(collect([$estimate]));
88
             }
94
             }
89
 
95
 
90
             $convertedAt = Carbon::parse($estimate->accepted_at)
96
             $convertedAt = Carbon::parse($estimate->accepted_at)
97
     public function declined(): static
103
     public function declined(): static
98
     {
104
     {
99
         return $this->afterCreating(function (Estimate $estimate) {
105
         return $this->afterCreating(function (Estimate $estimate) {
100
-            if (! $estimate->isApproved()) {
101
-                $this->approved()->create();
102
-            }
106
+            $this->ensureSent($estimate);
103
 
107
 
104
-            $declinedAt = Carbon::parse($estimate->approved_at)
108
+            $declinedAt = Carbon::parse($estimate->last_sent_at)
105
                 ->addDays($this->faker->numberBetween(1, 7));
109
                 ->addDays($this->faker->numberBetween(1, 7));
106
 
110
 
107
-            $estimate->update([
108
-                'status' => EstimateStatus::Declined,
109
-                'declined_at' => $declinedAt,
110
-            ]);
111
+            $estimate->markAsDeclined($declinedAt);
111
         });
112
         });
112
     }
113
     }
113
 
114
 
114
     public function sent(): static
115
     public function sent(): static
115
     {
116
     {
116
         return $this->afterCreating(function (Estimate $estimate) {
117
         return $this->afterCreating(function (Estimate $estimate) {
117
-            if (! $estimate->isDraft()) {
118
-                return;
119
-            }
118
+            $this->ensureApproved($estimate);
120
 
119
 
121
-            $this->recalculateTotals($estimate);
122
-
123
-            $sentAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
120
+            $sentAt = Carbon::parse($estimate->approved_at)
121
+                ->addHours($this->faker->numberBetween(1, 24));
124
 
122
 
125
             $estimate->markAsSent($sentAt);
123
             $estimate->markAsSent($sentAt);
126
         });
124
         });
127
     }
125
     }
128
 
126
 
127
+    public function viewed(): static
128
+    {
129
+        return $this->afterCreating(function (Estimate $estimate) {
130
+            $this->ensureSent($estimate);
131
+
132
+            $viewedAt = Carbon::parse($estimate->last_sent_at)
133
+                ->addHours($this->faker->numberBetween(1, 24));
134
+
135
+            $estimate->markAsViewed($viewedAt);
136
+        });
137
+    }
138
+
139
+    public function expired(): static
140
+    {
141
+        return $this
142
+            ->state([
143
+                'expiration_date' => now()->subDays($this->faker->numberBetween(1, 30)),
144
+            ])
145
+            ->afterCreating(function (Estimate $estimate) {
146
+                $this->ensureApproved($estimate);
147
+            });
148
+    }
149
+
129
     public function configure(): static
150
     public function configure(): static
130
     {
151
     {
131
         return $this->afterCreating(function (Estimate $estimate) {
152
         return $this->afterCreating(function (Estimate $estimate) {
153
+            $this->ensureLineItems($estimate);
154
+
132
             $paddedId = str_pad((string) $estimate->id, 5, '0', STR_PAD_LEFT);
155
             $paddedId = str_pad((string) $estimate->id, 5, '0', STR_PAD_LEFT);
133
 
156
 
134
             $estimate->updateQuietly([
157
             $estimate->updateQuietly([
136
                 'reference_number' => "REF-{$paddedId}",
159
                 'reference_number' => "REF-{$paddedId}",
137
             ]);
160
             ]);
138
 
161
 
139
-            $this->recalculateTotals($estimate);
140
-
141
-            if ($estimate->approved_at && $estimate->is_currently_expired) {
162
+            if ($estimate->wasApproved() && $estimate->is_currently_expired) {
142
                 $estimate->updateQuietly([
163
                 $estimate->updateQuietly([
143
                     'status' => EstimateStatus::Expired,
164
                     'status' => EstimateStatus::Expired,
144
                 ]);
165
                 ]);
146
         });
167
         });
147
     }
168
     }
148
 
169
 
170
+    protected function ensureLineItems(Estimate $estimate): void
171
+    {
172
+        if (! $estimate->hasLineItems()) {
173
+            $this->withLineItems()->callAfterCreating(collect([$estimate]));
174
+        }
175
+    }
176
+
177
+    protected function ensureApproved(Estimate $estimate): void
178
+    {
179
+        if (! $estimate->wasApproved()) {
180
+            $this->approved()->callAfterCreating(collect([$estimate]));
181
+        }
182
+    }
183
+
184
+    protected function ensureSent(Estimate $estimate): void
185
+    {
186
+        if (! $estimate->hasBeenSent()) {
187
+            $this->sent()->callAfterCreating(collect([$estimate]));
188
+        }
189
+    }
190
+
149
     protected function recalculateTotals(Estimate $estimate): void
191
     protected function recalculateTotals(Estimate $estimate): void
150
     {
192
     {
151
-        if ($estimate->lineItems()->exists()) {
152
-            $estimate->refresh();
153
-            $subtotal = $estimate->lineItems()->sum('subtotal') / 100;
154
-            $taxTotal = $estimate->lineItems()->sum('tax_total') / 100;
155
-            $discountTotal = $estimate->lineItems()->sum('discount_total') / 100;
156
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
157
-
158
-            $estimate->update([
159
-                'subtotal' => $subtotal,
160
-                'tax_total' => $taxTotal,
161
-                'discount_total' => $discountTotal,
162
-                'total' => $grandTotal,
163
-            ]);
193
+        $estimate->refresh();
194
+
195
+        if (! $estimate->hasLineItems()) {
196
+            return;
164
         }
197
         }
198
+
199
+        $subtotal = $estimate->lineItems()->sum('subtotal') / 100;
200
+        $taxTotal = $estimate->lineItems()->sum('tax_total') / 100;
201
+        $discountTotal = $estimate->lineItems()->sum('discount_total') / 100;
202
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
203
+
204
+        $estimate->update([
205
+            'subtotal' => $subtotal,
206
+            'tax_total' => $taxTotal,
207
+            'discount_total' => $discountTotal,
208
+            'total' => $grandTotal,
209
+        ]);
165
     }
210
     }
166
 }
211
 }

+ 125
- 43
database/factories/Accounting/InvoiceFactory.php 查看文件

49
         ];
49
         ];
50
     }
50
     }
51
 
51
 
52
-    public function withLineItems(int $count = 3): self
52
+    public function withLineItems(int $count = 3): static
53
     {
53
     {
54
-        return $this->has(DocumentLineItem::factory()->forInvoice()->count($count), 'lineItems');
54
+        return $this->afterCreating(function (Invoice $invoice) use ($count) {
55
+            DocumentLineItem::factory()
56
+                ->count($count)
57
+                ->forInvoice($invoice)
58
+                ->create();
59
+
60
+            $this->recalculateTotals($invoice);
61
+        });
55
     }
62
     }
56
 
63
 
57
     public function approved(): static
64
     public function approved(): static
58
     {
65
     {
59
         return $this->afterCreating(function (Invoice $invoice) {
66
         return $this->afterCreating(function (Invoice $invoice) {
60
-            if (! $invoice->isDraft()) {
67
+            $this->ensureLineItems($invoice);
68
+
69
+            if (! $invoice->canBeApproved()) {
61
                 return;
70
                 return;
62
             }
71
             }
63
 
72
 
64
-            $this->recalculateTotals($invoice);
65
-
66
-            $approvedAt = Carbon::parse($invoice->date)->addHours($this->faker->numberBetween(1, 24));
73
+            $approvedAt = Carbon::parse($invoice->date)
74
+                ->addHours($this->faker->numberBetween(1, 24));
67
 
75
 
68
             $invoice->approveDraft($approvedAt);
76
             $invoice->approveDraft($approvedAt);
69
         });
77
         });
70
     }
78
     }
71
 
79
 
72
-    public function withPayments(?int $min = 1, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
80
+    public function sent(): static
73
     {
81
     {
74
-        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
75
-            if ($invoice->isDraft()) {
76
-                $this->recalculateTotals($invoice);
82
+        return $this->afterCreating(function (Invoice $invoice) {
83
+            $this->ensureApproved($invoice);
77
 
84
 
78
-                $approvedAt = Carbon::parse($invoice->date)->addHours($this->faker->numberBetween(1, 24));
79
-                $invoice->approveDraft($approvedAt);
80
-            }
85
+            $sentAt = Carbon::parse($invoice->approved_at)
86
+                ->addHours($this->faker->numberBetween(1, 24));
87
+
88
+            $invoice->markAsSent($sentAt);
89
+        });
90
+    }
91
+
92
+    public function partial(int $maxPayments = 4): static
93
+    {
94
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
95
+            $this->ensureSent($invoice);
96
+
97
+            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Partial)
98
+                ->callAfterCreating(collect([$invoice]));
99
+        });
100
+    }
101
+
102
+    public function paid(int $maxPayments = 4): static
103
+    {
104
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
105
+            $this->ensureSent($invoice);
106
+
107
+            $this->withPayments(max: $maxPayments)
108
+                ->callAfterCreating(collect([$invoice]));
109
+        });
110
+    }
111
+
112
+    public function overpaid(int $maxPayments = 4): static
113
+    {
114
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
115
+            $this->ensureSent($invoice);
116
+
117
+            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Overpaid)
118
+                ->callAfterCreating(collect([$invoice]));
119
+        });
120
+    }
121
+
122
+    public function overdue(): static
123
+    {
124
+        return $this
125
+            ->state([
126
+                'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
127
+            ])
128
+            ->afterCreating(function (Invoice $invoice) {
129
+                $this->ensureApproved($invoice);
130
+            });
131
+    }
132
+
133
+    public function withPayments(?int $min = null, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
134
+    {
135
+        $min ??= 1;
136
+
137
+        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
138
+            $this->ensureSent($invoice);
81
 
139
 
82
             $invoice->refresh();
140
             $invoice->refresh();
83
 
141
 
84
-            $totalAmountDue = $invoice->getRawOriginal('amount_due');
142
+            $amountDue = $invoice->getRawOriginal('amount_due');
85
 
143
 
86
-            if ($invoiceStatus === InvoiceStatus::Overpaid) {
87
-                $totalAmountDue += random_int(1000, 10000);
88
-            } elseif ($invoiceStatus === InvoiceStatus::Partial) {
89
-                $totalAmountDue = (int) floor($totalAmountDue * 0.5);
90
-            }
144
+            $totalAmountDue = match ($invoiceStatus) {
145
+                InvoiceStatus::Overpaid => $amountDue + random_int(1000, 10000),
146
+                InvoiceStatus::Partial => (int) floor($amountDue * 0.5),
147
+                default => $amountDue,
148
+            };
91
 
149
 
92
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
150
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
93
                 return;
151
                 return;
122
                 $remainingAmount -= $amount;
180
                 $remainingAmount -= $amount;
123
             }
181
             }
124
 
182
 
125
-            // If it's a paid invoice, use the latest payment date as paid_at
126
-            if ($invoiceStatus === InvoiceStatus::Paid) {
127
-                $latestPaymentDate = max($paymentDates);
128
-                $invoice->updateQuietly([
129
-                    'status' => $invoiceStatus,
130
-                    'paid_at' => $latestPaymentDate,
131
-                ]);
183
+            if ($invoiceStatus !== InvoiceStatus::Paid) {
184
+                return;
132
             }
185
             }
186
+
187
+            $latestPaymentDate = max($paymentDates);
188
+            $invoice->updateQuietly([
189
+                'status' => $invoiceStatus,
190
+                'paid_at' => $latestPaymentDate,
191
+            ]);
133
         });
192
         });
134
     }
193
     }
135
 
194
 
136
     public function configure(): static
195
     public function configure(): static
137
     {
196
     {
138
         return $this->afterCreating(function (Invoice $invoice) {
197
         return $this->afterCreating(function (Invoice $invoice) {
139
-            // Use the invoice's ID to generate invoice and order numbers
198
+            $this->ensureLineItems($invoice);
199
+
140
             $paddedId = str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT);
200
             $paddedId = str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT);
141
 
201
 
142
             $invoice->updateQuietly([
202
             $invoice->updateQuietly([
144
                 'order_number' => "ORD-{$paddedId}",
204
                 'order_number' => "ORD-{$paddedId}",
145
             ]);
205
             ]);
146
 
206
 
147
-            $this->recalculateTotals($invoice);
148
-
149
-            if ($invoice->approved_at && $invoice->is_currently_overdue) {
207
+            if ($invoice->wasApproved() && $invoice->is_currently_overdue) {
150
                 $invoice->updateQuietly([
208
                 $invoice->updateQuietly([
151
                     'status' => InvoiceStatus::Overdue,
209
                     'status' => InvoiceStatus::Overdue,
152
                 ]);
210
                 ]);
154
         });
212
         });
155
     }
213
     }
156
 
214
 
215
+    protected function ensureLineItems(Invoice $invoice): void
216
+    {
217
+        if (! $invoice->hasLineItems()) {
218
+            $this->withLineItems()->callAfterCreating(collect([$invoice]));
219
+        }
220
+    }
221
+
222
+    protected function ensureApproved(Invoice $invoice): void
223
+    {
224
+        if (! $invoice->wasApproved()) {
225
+            $this->approved()->callAfterCreating(collect([$invoice]));
226
+        }
227
+    }
228
+
229
+    protected function ensureSent(Invoice $invoice): void
230
+    {
231
+        if (! $invoice->hasBeenSent()) {
232
+            $this->sent()->callAfterCreating(collect([$invoice]));
233
+        }
234
+    }
235
+
157
     protected function recalculateTotals(Invoice $invoice): void
236
     protected function recalculateTotals(Invoice $invoice): void
158
     {
237
     {
159
-        if ($invoice->lineItems()->exists()) {
160
-            $invoice->refresh();
161
-            $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
162
-            $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
163
-            $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
164
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
165
-
166
-            $invoice->update([
167
-                'subtotal' => $subtotal,
168
-                'tax_total' => $taxTotal,
169
-                'discount_total' => $discountTotal,
170
-                'total' => $grandTotal,
171
-            ]);
238
+        $invoice->refresh();
239
+
240
+        if (! $invoice->hasLineItems()) {
241
+            return;
172
         }
242
         }
243
+
244
+        $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
245
+        $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
246
+        $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
247
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
248
+
249
+        $invoice->update([
250
+            'subtotal' => $subtotal,
251
+            'tax_total' => $taxTotal,
252
+            'discount_total' => $discountTotal,
253
+            'total' => $grandTotal,
254
+        ]);
173
     }
255
     }
174
 }
256
 }

+ 30
- 45
database/factories/CompanyFactory.php 查看文件

2
 
2
 
3
 namespace Database\Factories;
3
 namespace Database\Factories;
4
 
4
 
5
-use App\Enums\Accounting\BillStatus;
6
-use App\Enums\Accounting\InvoiceStatus;
7
 use App\Models\Accounting\Bill;
5
 use App\Models\Accounting\Bill;
8
 use App\Models\Accounting\Estimate;
6
 use App\Models\Accounting\Estimate;
9
 use App\Models\Accounting\Invoice;
7
 use App\Models\Accounting\Invoice;
107
             $draftCount = (int) floor($count * 0.2);
105
             $draftCount = (int) floor($count * 0.2);
108
             $approvedCount = (int) floor($count * 0.2);
106
             $approvedCount = (int) floor($count * 0.2);
109
             $paidCount = (int) floor($count * 0.3);
107
             $paidCount = (int) floor($count * 0.3);
110
-            $partialCount = (int) floor($count * 0.2);
111
-            $overpaidCount = $count - ($draftCount + $approvedCount + $paidCount + $partialCount);
108
+            $partialCount = (int) floor($count * 0.1);
109
+            $overpaidCount = (int) floor($count * 0.1);
110
+            $overdueCount = $count - ($draftCount + $approvedCount + $paidCount + $partialCount + $overpaidCount);
112
 
111
 
113
             Invoice::factory()
112
             Invoice::factory()
114
                 ->count($draftCount)
113
                 ->count($draftCount)
115
-                ->withLineItems()
116
                 ->create([
114
                 ->create([
117
                     'company_id' => $company->id,
115
                     'company_id' => $company->id,
118
                     'created_by' => $company->user_id,
116
                     'created_by' => $company->user_id,
121
 
119
 
122
             Invoice::factory()
120
             Invoice::factory()
123
                 ->count($approvedCount)
121
                 ->count($approvedCount)
124
-                ->withLineItems()
125
                 ->approved()
122
                 ->approved()
126
                 ->create([
123
                 ->create([
127
                     'company_id' => $company->id,
124
                     'company_id' => $company->id,
131
 
128
 
132
             Invoice::factory()
129
             Invoice::factory()
133
                 ->count($paidCount)
130
                 ->count($paidCount)
134
-                ->withLineItems()
135
-                ->approved()
136
-                ->withPayments(max: 4)
131
+                ->paid()
137
                 ->create([
132
                 ->create([
138
                     'company_id' => $company->id,
133
                     'company_id' => $company->id,
139
                     'created_by' => $company->user_id,
134
                     'created_by' => $company->user_id,
142
 
137
 
143
             Invoice::factory()
138
             Invoice::factory()
144
                 ->count($partialCount)
139
                 ->count($partialCount)
145
-                ->withLineItems()
146
-                ->approved()
147
-                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Partial)
140
+                ->partial()
148
                 ->create([
141
                 ->create([
149
                     'company_id' => $company->id,
142
                     'company_id' => $company->id,
150
                     'created_by' => $company->user_id,
143
                     'created_by' => $company->user_id,
153
 
146
 
154
             Invoice::factory()
147
             Invoice::factory()
155
                 ->count($overpaidCount)
148
                 ->count($overpaidCount)
156
-                ->withLineItems()
157
-                ->approved()
158
-                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Overpaid)
149
+                ->overpaid()
150
+                ->create([
151
+                    'company_id' => $company->id,
152
+                    'created_by' => $company->user_id,
153
+                    'updated_by' => $company->user_id,
154
+                ]);
155
+
156
+            Invoice::factory()
157
+                ->count($overdueCount)
158
+                ->overdue()
159
                 ->create([
159
                 ->create([
160
                     'company_id' => $company->id,
160
                     'company_id' => $company->id,
161
                     'created_by' => $company->user_id,
161
                     'created_by' => $company->user_id,
174
             $convertedCount = (int) floor($count * 0.1); // 10% converted to invoices
174
             $convertedCount = (int) floor($count * 0.1); // 10% converted to invoices
175
             $expiredCount = $count - ($draftCount + $approvedCount + $acceptedCount + $declinedCount + $convertedCount); // remaining 10%
175
             $expiredCount = $count - ($draftCount + $approvedCount + $acceptedCount + $declinedCount + $convertedCount); // remaining 10%
176
 
176
 
177
-            // Create draft estimates
178
             Estimate::factory()
177
             Estimate::factory()
179
                 ->count($draftCount)
178
                 ->count($draftCount)
180
-                ->withLineItems()
181
                 ->create([
179
                 ->create([
182
                     'company_id' => $company->id,
180
                     'company_id' => $company->id,
183
                     'created_by' => $company->user_id,
181
                     'created_by' => $company->user_id,
184
                     'updated_by' => $company->user_id,
182
                     'updated_by' => $company->user_id,
185
                 ]);
183
                 ]);
186
 
184
 
187
-            // Create pending (approved) estimates
188
             Estimate::factory()
185
             Estimate::factory()
189
                 ->count($approvedCount)
186
                 ->count($approvedCount)
190
-                ->withLineItems()
191
                 ->approved()
187
                 ->approved()
192
                 ->create([
188
                 ->create([
193
                     'company_id' => $company->id,
189
                     'company_id' => $company->id,
195
                     'updated_by' => $company->user_id,
191
                     'updated_by' => $company->user_id,
196
                 ]);
192
                 ]);
197
 
193
 
198
-            // Create accepted estimates
199
             Estimate::factory()
194
             Estimate::factory()
200
                 ->count($acceptedCount)
195
                 ->count($acceptedCount)
201
-                ->withLineItems()
202
                 ->accepted()
196
                 ->accepted()
203
                 ->create([
197
                 ->create([
204
                     'company_id' => $company->id,
198
                     'company_id' => $company->id,
206
                     'updated_by' => $company->user_id,
200
                     'updated_by' => $company->user_id,
207
                 ]);
201
                 ]);
208
 
202
 
209
-            // Create declined estimates
210
             Estimate::factory()
203
             Estimate::factory()
211
                 ->count($declinedCount)
204
                 ->count($declinedCount)
212
-                ->withLineItems()
213
                 ->declined()
205
                 ->declined()
214
                 ->create([
206
                 ->create([
215
                     'company_id' => $company->id,
207
                     'company_id' => $company->id,
217
                     'updated_by' => $company->user_id,
209
                     'updated_by' => $company->user_id,
218
                 ]);
210
                 ]);
219
 
211
 
220
-            // Create converted estimates
221
             Estimate::factory()
212
             Estimate::factory()
222
                 ->count($convertedCount)
213
                 ->count($convertedCount)
223
-                ->withLineItems()
224
-                ->accepted()
225
                 ->converted()
214
                 ->converted()
226
                 ->create([
215
                 ->create([
227
                     'company_id' => $company->id,
216
                     'company_id' => $company->id,
229
                     'updated_by' => $company->user_id,
218
                     'updated_by' => $company->user_id,
230
                 ]);
219
                 ]);
231
 
220
 
232
-            // Create expired estimates (approved but past expiration date)
233
             Estimate::factory()
221
             Estimate::factory()
234
                 ->count($expiredCount)
222
                 ->count($expiredCount)
235
-                ->withLineItems()
236
-                ->approved()
237
-                ->state([
238
-                    'expiration_date' => now()->subDays(rand(1, 30)),
239
-                ])
223
+                ->expired()
240
                 ->create([
224
                 ->create([
241
                     'company_id' => $company->id,
225
                     'company_id' => $company->id,
242
                     'created_by' => $company->user_id,
226
                     'created_by' => $company->user_id,
249
     {
233
     {
250
         return $this->afterCreating(function (Company $company) use ($count) {
234
         return $this->afterCreating(function (Company $company) use ($count) {
251
             $unpaidCount = (int) floor($count * 0.4);
235
             $unpaidCount = (int) floor($count * 0.4);
252
-            $paidCount = (int) floor($count * 0.4);
253
-            $partialCount = $count - ($unpaidCount + $paidCount);
236
+            $paidCount = (int) floor($count * 0.3);
237
+            $partialCount = (int) floor($count * 0.2);
238
+            $overdueCount = $count - ($unpaidCount + $paidCount + $partialCount);
254
 
239
 
255
-            // Create unpaid bills
256
             Bill::factory()
240
             Bill::factory()
257
                 ->count($unpaidCount)
241
                 ->count($unpaidCount)
258
-                ->withLineItems()
259
-                ->initialized()
260
                 ->create([
242
                 ->create([
261
                     'company_id' => $company->id,
243
                     'company_id' => $company->id,
262
                     'created_by' => $company->user_id,
244
                     'created_by' => $company->user_id,
263
                     'updated_by' => $company->user_id,
245
                     'updated_by' => $company->user_id,
264
                 ]);
246
                 ]);
265
 
247
 
266
-            // Create paid bills
267
             Bill::factory()
248
             Bill::factory()
268
                 ->count($paidCount)
249
                 ->count($paidCount)
269
-                ->withLineItems()
270
-                ->initialized()
271
-                ->withPayments(max: 4)
250
+                ->paid()
272
                 ->create([
251
                 ->create([
273
                     'company_id' => $company->id,
252
                     'company_id' => $company->id,
274
                     'created_by' => $company->user_id,
253
                     'created_by' => $company->user_id,
275
                     'updated_by' => $company->user_id,
254
                     'updated_by' => $company->user_id,
276
                 ]);
255
                 ]);
277
 
256
 
278
-            // Create partially paid bills
279
             Bill::factory()
257
             Bill::factory()
280
                 ->count($partialCount)
258
                 ->count($partialCount)
281
-                ->withLineItems()
282
-                ->initialized()
283
-                ->withPayments(max: 4, billStatus: BillStatus::Partial)
259
+                ->partial()
260
+                ->create([
261
+                    'company_id' => $company->id,
262
+                    'created_by' => $company->user_id,
263
+                    'updated_by' => $company->user_id,
264
+                ]);
265
+
266
+            Bill::factory()
267
+                ->count($overdueCount)
268
+                ->overdue()
284
                 ->create([
269
                 ->create([
285
                     'company_id' => $company->id,
270
                     'company_id' => $company->id,
286
                     'created_by' => $company->user_id,
271
                     'created_by' => $company->user_id,

+ 1
- 0
database/migrations/2024_11_27_223015_create_invoices_table.php 查看文件

26
             $table->timestamp('approved_at')->nullable();
26
             $table->timestamp('approved_at')->nullable();
27
             $table->timestamp('paid_at')->nullable();
27
             $table->timestamp('paid_at')->nullable();
28
             $table->timestamp('last_sent_at')->nullable();
28
             $table->timestamp('last_sent_at')->nullable();
29
+            $table->timestamp('last_viewed_at')->nullable();
29
             $table->string('status')->default('draft');
30
             $table->string('status')->default('draft');
30
             $table->string('currency_code')->nullable();
31
             $table->string('currency_code')->nullable();
31
             $table->string('discount_method')->default('per_line_item');
32
             $table->string('discount_method')->default('per_line_item');

Loading…
取消
儲存