feat: AfterSales CRM - SOP compliant real-estate customer care system
- Departments + cross-department transfer workflow - Role-based closing (staff→resolved, manager→closed) with proof_of_resolution - Private file storage with collection system + temporary signed URLs - Dashboard: resolved tickets table, stats, handler performance bar chart - Spatie RBAC (admin/manager/staff) - Notifications: TicketTransferred, TicketClosed (database + mail) - Pest tests: 8 tests, 21 assertions - Docker production-ready (Dockerfile + compose + entrypoint)
This commit is contained in:
74
app/Services/ClosingService.php
Normal file
74
app/Services/ClosingService.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class ClosingService
|
||||
{
|
||||
/**
|
||||
* Close a feedback. Throws if staff tries to close or no proof exists.
|
||||
*/
|
||||
public function close(Feedback $feedback, User $actor): void
|
||||
{
|
||||
if ($feedback->status === 'closed') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => __('Phiếu này đã được đóng.'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($actor->hasRole('staff')) {
|
||||
throw new HttpException(403, __('Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu.'));
|
||||
}
|
||||
|
||||
if ($actor->hasRole('manager') && ! $actor->hasRole('admin')) {
|
||||
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id');
|
||||
if (! $managedDeptIds->contains($feedback->current_department_id)) {
|
||||
throw new HttpException(403, __('Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.'));
|
||||
}
|
||||
}
|
||||
|
||||
$fileService = App::make(FileService::class);
|
||||
|
||||
if (! $fileService->hasCollection($feedback, 'proof_of_resolution')) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => __('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$feedback->update(['status' => 'closed']);
|
||||
|
||||
$this->notifyStakeholders($feedback, $actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feedback. Any role can resolve.
|
||||
*/
|
||||
public function resolve(Feedback $feedback, User $actor): void
|
||||
{
|
||||
if (in_array($feedback->status, ['closed', 'resolved'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => __('Phiếu này đã được xử lý hoặc đóng.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$feedback->update(['status' => 'resolved']);
|
||||
}
|
||||
|
||||
protected function notifyStakeholders(Feedback $feedback, User $actor): void
|
||||
{
|
||||
if ($feedback->assignedTo) {
|
||||
$feedback->assignedTo->notify(new \App\Notifications\TicketClosed($feedback, $actor));
|
||||
}
|
||||
|
||||
$department = $feedback->currentDepartment;
|
||||
if ($department && $department->manager && $department->manager->id !== ($feedback->assignedTo?->id ?? null)) {
|
||||
$department->manager->notify(new \App\Notifications\TicketClosed($feedback, $actor));
|
||||
}
|
||||
}
|
||||
}
|
||||
125
app/Services/FileService.php
Normal file
125
app/Services/FileService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\FeedbackAttachment;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class FileService
|
||||
{
|
||||
protected array $allowedMimes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'application/pdf',
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'video/mov',
|
||||
];
|
||||
|
||||
protected int $maxSize = 20480;
|
||||
|
||||
protected string $defaultDisk = 'local';
|
||||
|
||||
/**
|
||||
* Upload files and create FeedbackAttachment records.
|
||||
*/
|
||||
public function upload(
|
||||
array $files,
|
||||
string $collection,
|
||||
Model $model,
|
||||
int $userId,
|
||||
?int $interactionId = null,
|
||||
string $disk = 'local',
|
||||
): array {
|
||||
$records = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! $file instanceof UploadedFile || ! $file->isValid()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->validate($file);
|
||||
|
||||
$path = $file->store('feedback-attachments', $disk);
|
||||
|
||||
$records[] = FeedbackAttachment::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'feedback_id' => $model instanceof \App\Models\Feedback ? $model->id : null,
|
||||
'feedback_interaction_id' => $interactionId,
|
||||
'user_id' => $userId,
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'path' => $path,
|
||||
'disk' => $disk,
|
||||
'collection' => $collection,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a temporary URL for a file on a private disk.
|
||||
*/
|
||||
public function getTemporaryUrl(FeedbackAttachment $attachment, int $minutes = 10): string
|
||||
{
|
||||
if ($attachment->disk === 'public') {
|
||||
return Storage::disk('public')->url($attachment->path);
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'storage.local',
|
||||
now()->addMinutes($minutes),
|
||||
['path' => $attachment->path]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attachments of a specific collection for a model.
|
||||
*/
|
||||
public function getByCollection(Model $model, string $collection)
|
||||
{
|
||||
$query = FeedbackAttachment::byCollection($collection);
|
||||
|
||||
if ($model instanceof \App\Models\Feedback) {
|
||||
$query->where('feedback_id', $model->id);
|
||||
}
|
||||
|
||||
return $query->orderBy('created_at', 'desc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has at least one attachment in the given collection.
|
||||
*/
|
||||
public function hasCollection(Model $model, string $collection): bool
|
||||
{
|
||||
return $this->getByCollection($model, $collection)->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type and size.
|
||||
*/
|
||||
protected function validate(UploadedFile $file): void
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (! in_array($file->getMimeType(), $this->allowedMimes)) {
|
||||
$errors['file'] = __('File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.', ['type' => $file->getMimeType()]);
|
||||
}
|
||||
|
||||
if ($file->getSize() > $this->maxSize * 1024) {
|
||||
$errors['file'] = __('File size exceeds :max KB.', ['max' => $this->maxSize]);
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/Services/TransferService.php
Normal file
57
app/Services/TransferService.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\TicketTransferLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
/**
|
||||
* Transfer a feedback to another department.
|
||||
*/
|
||||
public function transfer(
|
||||
Feedback $feedback,
|
||||
Department $toDepartment,
|
||||
string $reason,
|
||||
User $sender,
|
||||
?int $newHandlerId = null,
|
||||
): TicketTransferLog {
|
||||
if (empty(trim($reason))) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => __('Vui lòng nhập lý do chuyển tiếp.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$fromDepartmentId = $feedback->current_department_id;
|
||||
|
||||
$log = TicketTransferLog::create([
|
||||
'feedback_id' => $feedback->id,
|
||||
'from_department_id' => $fromDepartmentId,
|
||||
'to_department_id' => $toDepartment->id,
|
||||
'sender_id' => $sender->id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
$feedback->update([
|
||||
'current_department_id' => $toDepartment->id,
|
||||
'assigned_to' => $newHandlerId ?? $toDepartment->manager_id ?? null,
|
||||
]);
|
||||
|
||||
$this->notifyDepartmentManager($toDepartment, $feedback, $log, $sender);
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
protected function notifyDepartmentManager(Department $department, Feedback $feedback, TicketTransferLog $log, User $sender): void
|
||||
{
|
||||
$manager = $department->manager;
|
||||
|
||||
if ($manager) {
|
||||
$manager->notify(new \App\Notifications\TicketTransferred($feedback, $log, $sender));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user