[FEAT] 實作公共事業費逾期提醒、租戶自訂通知設定及發送測試信功能
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 56s

This commit is contained in:
2026-03-05 16:01:00 +08:00
parent 016366407c
commit 07b7d9b327
15 changed files with 519 additions and 44 deletions

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class NotifyUtilityFeeStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'finance:notify-utility-fees';
/**
* The console command description.
*
* @var string
*/
protected $description = '檢查公共事業費狀態並寄送 Email 通知管理員';
/**
* Execute the console command.
*/
public function handle()
{
$this->info("正在掃描公共事業費狀態...");
// 1. 更新逾期狀態 (pending -> overdue)
\App\Modules\Finance\Models\UtilityFee::where('payment_status', \App\Modules\Finance\Models\UtilityFee::STATUS_PENDING)
->whereNotNull('due_date')
->where('due_date', '<', now()->startOfDay())
->update(['payment_status' => \App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE]);
// 2. 獲取需要處理的單據 (pending 或 overdue)
$unpaidFees = \App\Modules\Finance\Models\UtilityFee::whereIn('payment_status', [
\App\Modules\Finance\Models\UtilityFee::STATUS_PENDING,
\App\Modules\Finance\Models\UtilityFee::STATUS_OVERDUE
])
->orderBy('due_date', 'asc')
->get();
if ($unpaidFees->isEmpty()) {
$this->info("目前沒有需要繳納的公共事業費。");
return 0;
}
// 3. 讀取系統設定
$senderEmail = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_email');
$senderPassword = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_sender_password');
$recipientEmailsStr = \App\Modules\Core\Models\SystemSetting::getVal('notification.utility_fee_recipient_emails');
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
$this->warn("系統設定中缺乏完整的 Email 通知參數,跳過寄送通知。請至「系統設定」->「通知設定」完善資料。");
return 0;
}
// 4. 動態覆寫應用程式名稱與 SMTP Config
$tenantName = tenant('name') ?? config('app.name');
config([
'app.name' => $tenantName,
'mail.mailers.smtp.username' => $senderEmail,
'mail.mailers.smtp.password' => $senderPassword,
'mail.from.address' => $senderEmail,
'mail.from.name' => $tenantName . ' (系統通知)'
]);
// 清理原先可能的 Mailer 實例,確保使用新的 Config
\Illuminate\Support\Facades\Mail::purge();
// 5. 解析收件者並寄送 Email
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
if (empty($validRecipients)) {
$this->warn("無效的收件者 Email 格式,跳過寄送通知。");
return 0;
}
try {
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\PaymentReminderMail($unpaidFees));
$this->info("通知郵件已成功寄送至: " . implode(', ', $validRecipients));
} catch (\Exception $e) {
$this->error("Email 寄送失敗: " . $e->getMessage());
}
return 0;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentReminderMail extends Mailable
{
use Queueable, SerializesModels;
public $fees;
/**
* Create a new message instance.
*/
public function __construct($fees)
{
$this->fees = $fees;
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$tenantName = tenant('name') ?? '系統';
return new Envelope(
subject: "{$tenantName}】公共事業費繳費/逾期通知",
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.payment-reminder',
with: [
'fees' => $this->fees,
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TestNotificationMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct()
{
//
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
$tenantName = tenant('name') ?? '系統';
return new Envelope(
subject: "{$tenantName}】電子郵件通知測試",
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.test-notification',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -43,4 +43,55 @@ class SystemSettingController extends Controller
return redirect()->back()->with('success', '系統設定已更新');
}
/**
* 測試發送通知信
*/
public function testNotification(Request $request)
{
$validated = $request->validate([
'settings' => 'required|array',
'settings.*.key' => 'required|string',
'settings.*.value' => 'nullable|string',
]);
$settings = collect($validated['settings'])->pluck('value', 'key');
$senderEmail = $settings['notification.utility_fee_sender_email'] ?? null;
$senderPassword = $settings['notification.utility_fee_sender_password'] ?? null;
$recipientEmailsStr = $settings['notification.utility_fee_recipient_emails'] ?? null;
if (empty($senderEmail) || empty($senderPassword) || empty($recipientEmailsStr)) {
return back()->with('error', '請先填寫完整發信帳號、密碼及收件者信箱。');
}
// 動態覆寫應用程式名稱與 SMTP Config
$tenantName = tenant('name') ?? config('app.name');
config([
'app.name' => $tenantName,
'mail.mailers.smtp.username' => $senderEmail,
'mail.mailers.smtp.password' => $senderPassword,
'mail.from.address' => $senderEmail,
'mail.from.name' => $tenantName . ' (系統通知)'
]);
// 清理原先可能的 Mailer 實例,確保使用新的 Config
\Illuminate\Support\Facades\Mail::purge();
// 解析收件者
$recipients = array_map('trim', explode(',', $recipientEmailsStr));
$validRecipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL));
if (empty($validRecipients)) {
return back()->with('error', '無效的收件者 Email 格式。');
}
try {
\Illuminate\Support\Facades\Mail::to($validRecipients)->send(new \App\Mail\TestNotificationMail());
return back()->with('success', '測試信件已成功發送,請檢查收件匣。');
} catch (\Exception $e) {
return back()->with('error', '測試發信失敗: ' . $e->getMessage());
}
}
}

View File

@@ -60,6 +60,7 @@ Route::middleware('auth')->group(function () {
Route::middleware('permission:system.settings.view')->group(function () {
Route::get('/settings', [SystemSettingController::class, 'index'])->name('settings.index');
Route::post('/settings', [SystemSettingController::class, 'update'])->name('settings.update');
Route::post('/settings/test-notification', [SystemSettingController::class, 'testNotification'])->name('settings.test-notification');
});
});

View File

@@ -34,13 +34,16 @@ class UtilityFeeController extends Controller
public function store(Request $request)
{
$validated = $request->validate([
'transaction_date' => 'required|date',
'transaction_date' => 'nullable|date',
'due_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$validated['payment_status'] = $this->determineStatus($validated);
$fee = UtilityFee::create($validated);
activity()
@@ -55,13 +58,16 @@ class UtilityFeeController extends Controller
public function update(Request $request, UtilityFee $utility_fee)
{
$validated = $request->validate([
'transaction_date' => 'required|date',
'transaction_date' => 'nullable|date',
'due_date' => 'required|date',
'category' => 'required|string|max:255',
'amount' => 'required|numeric|min:0',
'invoice_number' => 'nullable|string|max:255',
'description' => 'nullable|string',
]);
$validated['payment_status'] = $this->determineStatus($validated);
$utility_fee->update($validated);
activity()
@@ -73,6 +79,22 @@ class UtilityFeeController extends Controller
return redirect()->back();
}
/**
* 判定繳費狀態
*/
private function determineStatus(array $data): string
{
if (!empty($data['transaction_date'])) {
return UtilityFee::STATUS_PAID;
}
if (!empty($data['due_date']) && now()->startOfDay()->gt(\Illuminate\Support\Carbon::parse($data['due_date']))) {
return UtilityFee::STATUS_OVERDUE;
}
return UtilityFee::STATUS_PENDING;
}
public function destroy(UtilityFee $utility_fee)
{
activity()

View File

@@ -10,26 +10,37 @@ class UtilityFee extends Model
/** @use HasFactory<\Database\Factories\UtilityFeeFactory> */
use HasFactory;
// 狀態常數
const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid';
const STATUS_OVERDUE = 'overdue';
protected $fillable = [
'transaction_date',
'due_date',
'category',
'amount',
'payment_status',
'invoice_number',
'description',
];
protected $casts = [
'transaction_date' => 'date:Y-m-d',
'due_date' => 'date:Y-m-d',
'amount' => 'decimal:2',
];
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
{
$activity->properties = $activity->properties->put('snapshot', [
'transaction_date' => $this->transaction_date->format('Y-m-d'),
$snapshot = [
'transaction_date' => $this->transaction_date?->format('Y-m-d'),
'due_date' => $this->due_date?->format('Y-m-d'),
'category' => $this->category,
'amount' => $this->amount,
'payment_status' => $this->payment_status,
'invoice_number' => $this->invoice_number,
]);
];
$activity->properties = $activity->properties->put('snapshot', $snapshot);
}
}