4 Commits
v2 ... main

Author SHA1 Message Date
a5af277294 Deployment: Fix Docker build failure by adding zip extension and .dockerignore
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
2026-05-21 04:09:00 +00:00
ffbfde943c Deployment: Upgrade Dockerfile with multi-stage asset building, add Redis service and queue workers 2026-05-21 04:00:30 +00:00
fc9158b928 Refactor: Enforce type safety, fix close deadlock, implement assignee restrictions and notify admin fallbacks
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
2026-05-20 10:42:30 +00:00
44a7f53deb Add feature tests for CSKH Excel import and database backup CLI commands 2026-05-20 10:34:18 +00:00
23 changed files with 2274 additions and 11 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
.git
.github
node_modules
vendor
.env
.env.*
!.env.example
bootstrap/cache/*.php
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
database/*.sqlite
public/build
README.md
CHANGELOG.md
TASKS_ROADMAP.md
AGENTS.md
PROGRESS.md
SOP_des.md
SYSTEM_ASSESSMENT_PROPOSALS.md
UI_DESIGN_BRIEF.md

View File

@@ -1,3 +1,12 @@
# Stage 1: Build front-end assets
FROM node:22-alpine AS assets-builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production PHP FPM + Nginx image
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache \
@@ -10,6 +19,8 @@ RUN apk add --no-cache \
freetype-dev \
oniguruma-dev \
libxml2-dev \
icu-dev \
libzip-dev \
zip \
unzip \
curl
@@ -25,7 +36,8 @@ RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
exif \
gd \
intl \
opcache
opcache \
zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
@@ -36,6 +48,9 @@ RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts --pr
COPY . .
# Copy Vite compiled assets
COPY --from=assets-builder /app/public/build ./public/build
RUN composer dump-autoload --optimize --no-dev \
&& mkdir -p storage/app/private/feedback-attachments \
&& mkdir -p storage/framework/{cache,sessions,views} \

View File

@@ -177,6 +177,9 @@ class EditFeedback extends EditRecord
$newStatus = $data['status'] ?? null;
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
// Save pending attachments first so ClosingService validation sees them
$this->savePendingAttachments();
$closingService = App::make(ClosingService::class);
$closingService->close($this->getRecord(), auth()->user());
}
@@ -184,7 +187,7 @@ class EditFeedback extends EditRecord
return $data;
}
protected function afterSave(): void
protected function savePendingAttachments(): void
{
if (empty($this->pendingAttachments)) {
return;
@@ -213,6 +216,13 @@ class EditFeedback extends EditRecord
'size' => $this->safeFileSize($disk, $path),
]);
}
$this->pendingAttachments = []; // Clear to prevent double saving in afterSave
}
protected function afterSave(): void
{
$this->savePendingAttachments();
}
protected function safeMimeType($disk, string $path): ?string

View File

@@ -59,7 +59,25 @@ class FeedbackForm
)
->visible(fn ($get): bool => ! $get('is_general'))
->nullable()
->live(),
->live()
->afterStateUpdated(function ($state, $set): void {
if (empty($state)) {
$set('contract_id', null);
return;
}
$cp = CustomerProduct::find($state);
if (! $cp) {
return;
}
$activeContract = \App\Models\Contract::where('product_id', $cp->product_id)
->where('status', 'active')
->first();
if ($activeContract) {
$set('contract_id', $activeContract->id);
} else {
$set('contract_id', null);
}
}),
Select::make('contract_id')
->label(__('app.contract'))

View File

@@ -7,6 +7,9 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Enums\ContractType;
use App\Enums\ContractStatus;
#[Fillable(['product_id', 'customer_id', 'type', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status'])]
class Contract extends Model
{
@@ -16,6 +19,8 @@ class Contract extends Model
'start_date' => 'date',
'end_date' => 'date',
'printed_at' => 'date',
'type' => ContractType::class,
'status' => ContractStatus::class,
];
}

View File

@@ -15,6 +15,11 @@ class Department extends Model
return $this->belongsTo(User::class, 'manager_id');
}
public function members(): HasMany
{
return $this->hasMany(User::class);
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class, 'current_department_id');

View File

@@ -6,9 +6,18 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Enums\HandoverStatus;
#[Fillable(['product_id', 'customer_id', 'handover_date', 'status', 'remark'])]
class Handover extends Model
{
protected function casts(): array
{
return [
'status' => HandoverStatus::class,
'handover_date' => 'date',
];
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);

View File

@@ -6,11 +6,23 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Enums\ServiceType;
use App\Enums\ContractStatus;
#[Fillable(['product_id', 'service_type', 'status', 'actual_collection_date', 'remark'])]
class ProductService extends Model
{
protected $table = 'product_services';
protected function casts(): array
{
return [
'service_type' => ServiceType::class,
'status' => ContractStatus::class,
'actual_collection_date' => 'date',
];
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);

View File

@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
@@ -14,7 +15,7 @@ use Illuminate\Contracts\Auth\Access\Authorizable;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
#[Fillable(['name', 'email', 'password', 'role'])]
#[Fillable(['name', 'email', 'password', 'role', 'department_id'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements FilamentUser
{
@@ -29,6 +30,11 @@ class User extends Authenticatable implements FilamentUser
];
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function canAccessPanel(Panel $panel): bool
{
return true;

View File

@@ -23,9 +23,12 @@ class ClosingService
throw new HttpException(403, __('feedback.close_permission_denied'));
}
if ($actor->hasRole('manager') && ! $actor->hasRole('admin')) {
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id');
if (! $managedDeptIds->contains($feedback->current_department_id)) {
if (! $actor->hasRole('admin')) {
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id')->toArray();
$actorDeptId = $actor->department_id;
$allowedDeptIds = array_filter(array_merge([$actorDeptId], $managedDeptIds));
if (! in_array($feedback->current_department_id, $allowedDeptIds)) {
throw new HttpException(403, __('feedback.close_department_only'));
}
}
@@ -68,13 +71,26 @@ class ClosingService
protected function notifyStakeholders(Feedback $feedback, User $actor): void
{
$notifiedUserIds = [];
if ($feedback->assignedTo) {
$feedback->assignedTo->notify(new \App\Notifications\TicketClosed($feedback, $actor));
$notifiedUserIds[] = $feedback->assignedTo->id;
}
$department = $feedback->currentDepartment;
if ($department && $department->manager && $department->manager->id !== ($feedback->assignedTo?->id ?? null)) {
$department->manager->notify(new \App\Notifications\TicketClosed($feedback, $actor));
if ($department && $department->manager) {
if (! in_array($department->manager->id, $notifiedUserIds)) {
$department->manager->notify(new \App\Notifications\TicketClosed($feedback, $actor));
}
} else {
// No manager! Notify admins
$admins = User::whereHas('roles', fn ($q) => $q->where('name', 'admin'))->get();
foreach ($admins as $admin) {
if (! in_array($admin->id, $notifiedUserIds)) {
$admin->notify(new \App\Notifications\TicketClosed($feedback, $actor));
}
}
}
}
}

View File

@@ -24,6 +24,20 @@ class TransferService
]);
}
if ($newHandlerId !== null) {
$handler = User::find($newHandlerId);
if (! $handler) {
throw ValidationException::withMessages([
'new_assignee_id' => __('feedback.user_not_found'),
]);
}
if ($handler->department_id !== $toDepartment->id && $toDepartment->manager_id !== $handler->id) {
throw ValidationException::withMessages([
'new_assignee_id' => __('feedback.handler_not_in_department'),
]);
}
}
$fromDepartmentId = $feedback->current_department_id;
$fromDepartmentName = $feedback->currentDepartment?->name ?? 'N/A';
@@ -67,6 +81,12 @@ class TransferService
if ($manager) {
$manager->notify(new \App\Notifications\TicketTransferred($feedback, $log, $sender));
} else {
// No manager! Notify admins
$admins = User::whereHas('roles', fn ($q) => $q->where('name', 'admin'))->get();
foreach ($admins as $admin) {
$admin->notify(new \App\Notifications\TicketTransferred($feedback, $log, $sender));
}
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('department_id')
->nullable()
->after('role')
->constrained('departments')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['department_id']);
$table->dropColumn('department_id');
});
}
};

View File

@@ -1,18 +1,35 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
image: minicrm:latest
container_name: minicrm_app
ports:
- "${APP_PORT:-8080}:80"
env_file:
- .env
volumes:
- ./database/database.sqlite:/var/www/html/database/database.sqlite
- ./database:/var/www/html/database
- ./storage/app:/var/www/html/storage/app
restart: unless-stopped
depends_on:
- redis
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/admin/login"]
interval: 30s
timeout: 5s
retries: 3
redis:
image: redis:7-alpine
container_name: minicrm_redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:

View File

@@ -3,8 +3,9 @@ set -e
if [ ! -f /var/www/html/database/database.sqlite ]; then
touch /var/www/html/database/database.sqlite
chown www-data:www-data /var/www/html/database/database.sqlite
fi
chown -R www-data:www-data /var/www/html/database
chmod -R 775 /var/www/html/database
if [ -z "$APP_KEY" ]; then
php artisan key:generate --force --no-interaction

View File

@@ -13,3 +13,16 @@ stdout_logfile_maxbytes=0
command=nginx -g 'daemon off;'
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@@ -10,6 +10,8 @@ return [
// TransferService
'transfer_reason_required' => 'Please enter a transfer reason.',
'handler_not_in_department' => 'The selected handler does not belong to the target department.',
'user_not_found' => 'The selected handler was not found.',
// FileService
'file_type_not_allowed' => 'File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.',

View File

@@ -10,6 +10,8 @@ return [
// TransferService
'transfer_reason_required' => 'Vui lòng nhập lý do chuyển tiếp.',
'handler_not_in_department' => 'Người xử lý được chọn không thuộc phòng ban đích.',
'user_not_found' => 'Không tìm thấy người xử lý được chọn.',
// FileService
'file_type_not_allowed' => 'Loại file :type không được phép. Cho phép: jpg, png, pdf, mp4, mov.',

1614
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -187,3 +187,157 @@ test('manager cannot close ticket from department they do not manage', function
expect($feedback->fresh()->status)->toBe('resolved');
});
// ─── Scenario 2d: Staff with close permission cannot close ticket from other department ──
test('staff with close permission cannot close ticket from other department', function () {
$staffRole = Role::create(['name' => 'staff']);
$staffRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
// Staff belongs to Department A
$deptA = Department::create(['name' => 'CSKH']);
$staff = User::factory()->create(['department_id' => $deptA->id]);
$staff->assignRole('staff');
// Ticket belongs to Department B
$deptB = Department::create(['name' => 'Ky Thuat']);
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-staff-close']);
$customer = Customer::create(['name' => 'Test Customer Staff', 'email' => 'staff@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Test Feedback Staff',
'content' => 'Test content',
'status' => 'resolved',
'current_department_id' => $deptB->id,
]);
FeedbackAttachment::create([
'uuid' => 'test-uuid-staff-1',
'feedback_id' => $feedback->id,
'user_id' => $staff->id,
'name' => 'proof.pdf',
'path' => 'feedback-attachments/proof_staff.pdf',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
$this->actingAs($staff);
$closingService = app(ClosingService::class);
try {
$closingService->close($feedback, $staff);
$this->fail('Expected HttpException (403) but none thrown.');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
expect($e->getStatusCode())->toBe(403);
expect($e->getMessage())->toBe('Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.');
}
expect($feedback->fresh()->status)->toBe('resolved');
});
// ─── Scenario 2e: Staff with close permission can close ticket from same department ────
test('staff with close permission can close ticket from same department', function () {
$staffRole = Role::create(['name' => 'staff']);
$staffRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
// Staff belongs to Department A
$deptA = Department::create(['name' => 'CSKH']);
$staff = User::factory()->create(['department_id' => $deptA->id]);
$staff->assignRole('staff');
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-staff-close-success']);
$customer = Customer::create(['name' => 'Test Customer Staff Success', 'email' => 'staff_success@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Test Feedback Staff Success',
'content' => 'Test content',
'status' => 'resolved',
'current_department_id' => $deptA->id,
]);
FeedbackAttachment::create([
'uuid' => 'test-uuid-staff-2',
'feedback_id' => $feedback->id,
'user_id' => $staff->id,
'name' => 'proof.pdf',
'path' => 'feedback-attachments/proof_staff2.pdf',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
$this->actingAs($staff);
$closingService = app(ClosingService::class);
$closingService->close($feedback, $staff);
expect($feedback->fresh()->status)->toBe('closed');
});
// ─── Scenario 2f: Notify admin when closing a department ticket with no manager ────────
test('admin notification is sent when closing department ticket with no manager', function () {
\Illuminate\Support\Facades\Notification::fake();
// Create admin role and user
$adminRole = Role::create(['name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('admin');
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
// Department with no manager
$dept = Department::create(['name' => 'Unmanaged Dept', 'manager_id' => null]);
$manager = User::factory()->create(['department_id' => $dept->id]);
$manager->assignRole('manager');
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-no-manager']);
$customer = Customer::create(['name' => 'Customer', 'email' => 'no_manager@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Ticket',
'content' => 'Content',
'status' => 'resolved',
'current_department_id' => $dept->id,
]);
FeedbackAttachment::create([
'uuid' => 'test-uuid-no-manager',
'feedback_id' => $feedback->id,
'user_id' => $manager->id,
'name' => 'proof.pdf',
'path' => 'feedback-attachments/proof3.pdf',
'disk' => 'local',
'collection' => 'proof_of_resolution',
'mime_type' => 'application/pdf',
'size' => 1024,
]);
$this->actingAs($manager);
$closingService = app(ClosingService::class);
$closingService->close($feedback, $manager);
expect($feedback->fresh()->status)->toBe('closed');
// Assert notification sent to admin
\Illuminate\Support\Facades\Notification::assertSentTo(
$admin,
\App\Notifications\TicketClosed::class,
function ($notification) use ($feedback, $manager) {
return $notification->feedback->id === $feedback->id
&& $notification->actor->id === $manager->id;
}
);
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\Customer;
use App\Models\Department;
use App\Models\Feedback;
use App\Models\FeedbackChannel;
use App\Models\User;
use App\Filament\Resources\Feedback\Pages\EditFeedback;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Livewire\Livewire;
use Illuminate\Support\Facades\Storage;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
Permission::firstOrCreate(['name' => 'close-ticket']);
Permission::firstOrCreate(['name' => 'view-feedback']);
Permission::firstOrCreate(['name' => 'update-feedback']);
Permission::firstOrCreate(['name' => 'transfer-department']);
Permission::firstOrCreate(['name' => 'delete-feedback']);
Permission::firstOrCreate(['name' => 'create-feedback']);
});
test('can upload proof and close ticket in one save operation', function () {
Storage::fake('public');
// Create manager who has close permission
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo(['close-ticket', 'view-feedback', 'update-feedback']);
$manager = User::factory()->create();
$manager->assignRole('manager');
$dept = Department::create(['name' => 'CSKH', 'manager_id' => $manager->id]);
$channel = FeedbackChannel::create(['name' => 'Email', 'slug' => 'email']);
$customer = Customer::create(['name' => 'Test Customer', 'email' => 'test@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Test Feedback',
'content' => 'Test content',
'status' => 'resolved',
'current_department_id' => $dept->id,
]);
// Mock an uploaded file on disk
$path = 'feedback-attachments/mock-proof.pdf';
Storage::disk('public')->put($path, 'dummy content');
$this->actingAs($manager);
// Run Filament form test
Livewire::test(EditFeedback::class, ['record' => $feedback->id])
->fillForm([
'status' => 'closed',
'attachment_collection' => 'proof_of_resolution',
'attachments' => [$path],
])
->call('save')
->assertHasNoFormErrors();
// Verify feedback is closed
expect($feedback->fresh()->status)->toBe('closed');
// Verify attachment exists in database
expect($feedback->attachments()->where('collection', 'proof_of_resolution')->exists())->toBeTrue();
});

View File

@@ -0,0 +1,55 @@
<?php
use App\Models\User;
use App\Models\Product;
use Illuminate\Support\Facades\File;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('export backup command exports database tables to json and writes manifest', function () {
// Create some data
User::factory()->count(3)->create();
Product::create(['name' => 'Sunrise Suite A1', 'status' => 'active']);
Product::create(['name' => 'Sunset Suite A2', 'status' => 'active']);
$backupPath = base_path('tests/Feature/backups_test');
// Clean backup directory if it exists
if (File::isDirectory($backupPath)) {
File::deleteDirectory($backupPath);
}
// Run backup command
$this->artisan('app:export-backup', [
'--path' => $backupPath,
'--tables' => 'users,products',
'--pretty' => true
])
->assertExitCode(0);
// Verify backup folder created
$directories = File::directories($backupPath);
expect($directories)->toHaveCount(1);
$backupDir = $directories[0];
// Verify files created
expect(File::exists("{$backupDir}/manifest.json"))->toBeTrue();
expect(File::exists("{$backupDir}/users.json"))->toBeTrue();
expect(File::exists("{$backupDir}/products.json"))->toBeTrue();
// Verify manifest contents
$manifest = json_decode(File::get("{$backupDir}/manifest.json"), true);
expect($manifest['tables']['users'])->toBe(3);
expect($manifest['tables']['products'])->toBe(2);
expect($manifest['total_rows'])->toBe(5);
// Verify products contents
$productsJson = json_decode(File::get("{$backupDir}/products.json"), true);
expect($productsJson)->toHaveCount(2);
expect($productsJson[0]['name'])->toBe('Sunrise Suite A1');
expect($productsJson[1]['name'])->toBe('Sunset Suite A2');
// Clean up backup directory
File::deleteDirectory($backupPath);
});

View File

@@ -0,0 +1,127 @@
<?php
use App\Models\Contract;
use App\Models\Customer;
use App\Models\Feedback;
use App\Models\FeedbackChannel;
use App\Models\FeedbackInteraction;
use App\Models\Handover;
use App\Models\Product;
use App\Models\ProductService;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
// Seed a default user since the importer associates interactions with the first user
User::factory()->create([
'name' => 'Default Agent',
'email' => 'agent@test.com',
]);
// Create a default FeedbackChannel (ID 1) which is required by the importer
FeedbackChannel::create([
'name' => 'Email',
'slug' => 'email',
'icon' => 'heroicon-o-envelope',
'color' => '#3b82f6'
]);
});
test('cskh import command processes rows correctly in dry run and active modes', function () {
// Create temporary CSV file inside the project directory so WSL has easy access
$csvPath = base_path('tests/Feature/cskh_test.csv');
// Headers: Mã căn hộ, Họ tên KH, BÀN GIÀO CĂN HỘ, TÌNH TRẠNG, HỢP ĐỒNG THUÊ, HĐ HỢP TÁC KINH DOANH, TỰ DOANH, Phí QL, Lịch Sử khách hàng
$headers = [
'Mã căn hộ',
'Họ tên KH',
'BÀN GIÀO CĂN HỘ',
'TÌNH TRẠNG',
'HỢP ĐỒNG THUÊ',
'HĐ HỢP TÁC KINH DOANH',
'TỰ DOANH',
'Phí QL',
'Lịch Sử khách hàng'
];
$row1 = [
'A1-101',
'Nguyen Van A',
'{"date":"2026-01-01 00:00:00.000000","timezone_type":3,"timezone":"UTC"}',
'Đã bàn giao căn hộ cho KH',
'Số HĐ: HĐ-001, Giá: 10M, Thời hạn: 1 năm',
'',
'',
'Đã thu phí QL tháng 1',
"KH báo hỏng bóng đèn\nKỹ thuật đã sửa xong"
];
$row2 = [
'A1-102',
'Tran Thi B',
'',
'',
'',
'HĐ hợp tác BCC-123',
'Doanh nghiệp B',
'',
'Khách hàng thắc mắc hóa đơn tiền nước'
];
$fp = fopen($csvPath, 'w');
// Write UTF-8 BOM for Excel support
fprintf($fp, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($fp, $headers);
fputcsv($fp, $row1);
fputcsv($fp, $row2);
fclose($fp);
// 1. Dry run execution
$this->artisan('app:import-cskh', [
'file_path' => $csvPath,
'--dry-run' => true
])
->expectsOutputToContain('Total rows in file: 2')
->expectsOutputToContain('New Products')
->assertExitCode(0);
// Verify no records created in dry-run
expect(Product::count())->toBe(0);
expect(Customer::count())->toBe(0);
expect(Contract::count())->toBe(0);
expect(Handover::count())->toBe(0);
expect(ProductService::count())->toBe(0);
expect(Feedback::count())->toBe(0);
// 2. Active run execution
$this->artisan('app:import-cskh', [
'file_path' => $csvPath
])
->expectsOutputToContain('Total rows in file: 2')
->assertExitCode(0);
// Verify records created
expect(Product::count())->toBe(2);
expect(Customer::count())->toBe(2);
expect(Contract::count())->toBe(2); // 1 Lease (row1) and 1 BCC (row2)
expect(Handover::count())->toBe(1); // row1 has handover, row2 doesn't
expect(ProductService::count())->toBe(1); // row2 has Self-Business
expect(Feedback::count())->toBe(2); // both have feedback logs
expect(FeedbackInteraction::count())->toBe(3); // row1 has 2 lines, row2 has 1 line
// Verify relationships and fields
$productA = Product::where('name', 'A1-101')->first();
expect($productA)->not->toBeNull();
$handover = Handover::where('product_id', $productA->id)->first();
expect($handover)->not->toBeNull();
expect($handover->handover_date->format('Y-m-d'))->toBe('2026-01-01');
$contract = Contract::where('product_id', $productA->id)->first();
expect($contract)->not->toBeNull();
expect($contract->type->value)->toBe('lease'); // type is ContractType enum
// Clean up temporary file
@unlink($csvPath);
});

View File

@@ -119,3 +119,44 @@ test('transfer without reason throws validation error', function () {
expect(TicketTransferLog::count())->toBe(0);
});
// ─── Transfer to invalid handler should fail ─────────────────────────
test('transfer to handler not in target department throws validation error', function () {
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo(['transfer-department', 'view-feedback', 'create-feedback', 'update-feedback']);
$sender = User::factory()->create();
$sender->assignRole('manager');
$fromDept = Department::create(['name' => 'CSKH']);
$toDept = Department::create(['name' => 'Ky Thuat']);
// Handler belongs to Finance department
$financeDept = Department::create(['name' => 'Tai Chinh']);
$handler = User::factory()->create(['department_id' => $financeDept->id]);
$channel = FeedbackChannel::create(['name' => 'Phone', 'slug' => 'phone-transfer']);
$customer = Customer::create(['name' => 'Test', 'email' => 't@test.com']);
$feedback = Feedback::create([
'customer_id' => $customer->id,
'feedback_channel_id' => $channel->id,
'title' => 'Transfer To Invalid Handler',
'content' => 'Test',
'status' => 'pending',
'current_department_id' => $fromDept->id,
]);
$this->actingAs($sender);
$transferService = app(TransferService::class);
try {
$transferService->transfer($feedback, $toDept, 'Chuyen sang kiem tra', $sender, $handler->id);
$this->fail('Expected ValidationException but none thrown.');
} catch (\Illuminate\Validation\ValidationException $e) {
expect($e->errors()['new_assignee_id'][0])->toContain('Người xử lý được chọn không thuộc phòng ban đích');
}
expect(TicketTransferLog::count())->toBe(0);
});