feat: granular permissions, export backup, user/role management
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

- PermissionSeeder: 29 permissions with role mapping (admin/manager/staff)
- Policies updated to use hasPermissionTo() instead of hasRole()
- ExportBackup command: JSON per-table backup with chunk processing
- UserResource: CRUD users with role assignment (admin only)
- RoleResource: CRUD roles with permission assignment (admin only)
- UserPolicy + RolePolicy: admin-only access control
- ImportCskh: detailed skip logging with color in dry-run mode
- Widgets: permission-based visibility checks
- Tests: 8/8 pass (21 assertions)
This commit is contained in:
2026-05-02 04:47:10 +00:00
parent 7c5055075b
commit 3a8db5cae6
40 changed files with 1804 additions and 440 deletions

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class ExportBackup extends Command
{
protected $signature = 'app:export-backup
{--path= : Output directory path (default: storage/app/backups)}
{--tables= : Comma-separated list of tables to export (default: all)}
{--pretty : Pretty-print JSON output}';
protected $description = 'Export database backup to JSON files (one file per table)';
private array $stats = [];
public function handle(): int
{
$path = $this->option('path') ?? storage_path('app/backups');
$pretty = $this->option('pretty') ?? false;
$tablesFilter = $this->option('tables')
? array_map('trim', explode(',', $this->option('tables')))
: null;
if (! File::isDirectory($path)) {
File::makeDirectory($path, 0755, true);
}
$timestamp = now()->format('Y-m-d_His');
$backupDir = "{$path}/backup_{$timestamp}";
File::makeDirectory($backupDir, 0755, true);
$this->info("Backup directory: {$backupDir}");
$this->newLine();
$tables = $this->getTables();
if ($tablesFilter) {
$tables = array_filter($tables, fn ($t) => in_array($t, $tablesFilter));
}
$this->info("Exporting " . count($tables) . " tables...");
$this->newLine();
$bar = $this->output->createProgressBar(count($tables));
$bar->start();
$totalRows = 0;
foreach ($tables as $table) {
$count = $this->exportTable($table, $backupDir, $pretty);
$this->stats[$table] = $count;
$totalRows += $count;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
// Write manifest
$manifest = [
'created_at' => now()->toIso8601String(),
'database' => config('database.default'),
'tables' => $this->stats,
'total_rows' => $totalRows,
];
File::put("{$backupDir}/manifest.json", json_encode($manifest, JSON_PRETTY_PRINT));
$this->printSummary($backupDir, $totalRows);
return self::SUCCESS;
}
private function getTables(): array
{
$driver = config('database.default');
if ($driver === 'sqlite') {
$results = DB::select("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
return array_map(fn ($r) => $r->name, $results);
}
return DB::getDoctrineSchemaManager()->listTableNames();
}
private function exportTable(string $table, string $backupDir, bool $pretty): int
{
$filename = "{$backupDir}/{$table}.json";
$count = 0;
$handle = fopen($filename, 'w');
fwrite($handle, "[\n");
$first = true;
DB::table($table)->orderBy('id')->chunk(500, function ($rows) use ($handle, &$count, &$first, $pretty) {
foreach ($rows as $row) {
if (! $first) {
fwrite($handle, ",\n");
}
$first = false;
$data = (array) $row;
$json = $pretty
? json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: json_encode($data, JSON_UNESCAPED_UNICODE);
fwrite($handle, $json);
$count++;
}
});
fwrite($handle, "\n]\n");
fclose($handle);
return $count;
}
private function printSummary(string $backupDir, int $totalRows): void
{
$rows = [];
foreach ($this->stats as $table => $count) {
$rows[] = [$table, $count];
}
$rows[] = ['<bold>TOTAL</bold>', "<bold>{$totalRows}</bold>"];
$this->table(['Table', 'Rows'], $rows);
$size = File::size($backupDir);
$sizeFormatted = $this->formatBytes($size);
$this->info("Backup size: {$sizeFormatted}");
$this->info("Location: {$backupDir}");
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
}

View File

@@ -31,6 +31,11 @@ class ImportCskh extends Command
private int $statsFeedbacks = 0;
private int $statsInteractions = 0;
private int $statsSkipped = 0;
private int $statsSkippedEmptyCode = 0;
private int $statsSkippedNumericCode = 0;
private int $statsSkippedHeaderRow = 0;
private int $statsSkippedEmptyCustomer = 0;
private array $skippedRowDetails = [];
public function handle(): int
{
@@ -63,8 +68,9 @@ class ImportCskh extends Command
foreach ($rows as $row) {
$rowCount++;
if ($this->isMetaRow($row)) {
$this->statsSkipped++;
$metaReason = $this->getMetaRowReason($row);
if ($metaReason !== null) {
$this->recordSkip($rowCount, $metaReason, $row);
$bar->advance();
continue;
@@ -73,7 +79,7 @@ class ImportCskh extends Command
$chunk[] = $row;
if (count($chunk) >= $chunkSize) {
$this->processChunk($chunk, $dryRun);
$this->processChunk($chunk, $dryRun, $rowCount);
$chunk = [];
}
@@ -81,7 +87,7 @@ class ImportCskh extends Command
}
if (! empty($chunk)) {
$this->processChunk($chunk, $dryRun);
$this->processChunk($chunk, $dryRun, $rowCount);
}
$bar->finish();
@@ -92,44 +98,78 @@ class ImportCskh extends Command
}
private function isMetaRow(array $row): bool
{
return $this->getMetaRowReason($row) !== null;
}
private function getMetaRowReason(array $row): ?string
{
$code = $row['Mã căn hộ'] ?? '';
if (empty($code) || is_numeric($code)) {
return true;
if (empty($code)) {
return 'empty_code';
}
if (is_numeric($code)) {
return 'numeric_code';
}
if ($code === 'Ngày TT' || $code === 'Tình trạng' || $code === 'Tên DN') {
return true;
return 'header_row';
}
return false;
return null;
}
private function processChunk(array $rows, bool $dryRun): void
private function recordSkip(int $rowNum, string $reason, array $row): void
{
$this->statsSkipped++;
match ($reason) {
'empty_code' => $this->statsSkippedEmptyCode++,
'numeric_code' => $this->statsSkippedNumericCode++,
'header_row' => $this->statsSkippedHeaderRow++,
default => null,
};
$code = $row['Mã căn hộ'] ?? '';
$this->skippedRowDetails[] = [
'row' => $rowNum,
'reason' => $reason,
'code' => $code,
];
}
private function processChunk(array $rows, bool $dryRun, int $currentRowNum): void
{
if ($dryRun) {
foreach ($rows as $row) {
$this->processRow($row, true);
foreach ($rows as $i => $row) {
$this->processRow($row, true, $currentRowNum - count($rows) + $i + 1);
}
return;
}
DB::transaction(function () use ($rows): void {
foreach ($rows as $row) {
$this->processRow($row, false);
DB::transaction(function () use ($rows, $currentRowNum): void {
foreach ($rows as $i => $row) {
$this->processRow($row, false, $currentRowNum - count($rows) + $i + 1);
}
});
}
private function processRow(array $row, bool $dryRun): void
private function processRow(array $row, bool $dryRun, int $rowNum): void
{
$productCode = trim($row['Mã căn hộ'] ?? '');
$customerName = trim($row['Họ tên KH'] ?? '');
if (empty($productCode) || empty($customerName)) {
$this->statsSkipped++;
$this->statsSkippedEmptyCustomer++;
$this->skippedRowDetails[] = [
'row' => $rowNum,
'reason' => 'empty_customer',
'code' => $productCode,
];
return;
}
@@ -433,6 +473,10 @@ class ImportCskh extends Command
[
['Total rows in file', $totalRows],
['Skipped (meta/empty)', $this->statsSkipped],
[' ├─ Empty code (row trống)', $this->statsSkippedEmptyCode],
[' ├─ Numeric code (hàng tổng)', $this->statsSkippedNumericCode],
[' ├─ Header row (tiêu đề)', $this->statsSkippedHeaderRow],
[' └─ Empty customer name', $this->statsSkippedEmptyCustomer],
[$mode . 'New Products', $this->statsProducts],
[$mode . 'New Customers', $this->statsCustomers],
[$mode . 'New Contracts', $this->statsContracts],
@@ -442,5 +486,56 @@ class ImportCskh extends Command
[$mode . 'New Interactions', $this->statsInteractions],
]
);
if ($dryRun && ! empty($this->skippedRowDetails)) {
$this->newLine();
$this->warn('=== Chi tiết các dòng bị bỏ qua ===');
$this->newLine();
$reasonLabels = [
'empty_code' => '<fg=red>Trống mã căn hộ</>',
'numeric_code' => '<fg=yellow>Hàng tổng (numeric)</>',
'header_row' => '<fg=cyan>Hàng tiêu đề</>',
'empty_customer' => '<fg=magenta>Trống tên KH</>',
];
$reasonColors = [
'empty_code' => 'red',
'numeric_code' => 'yellow',
'header_row' => 'cyan',
'empty_customer' => 'magenta',
];
$grouped = [];
foreach ($this->skippedRowDetails as $detail) {
$grouped[$detail['reason']][] = $detail;
}
foreach ($grouped as $reason => $details) {
$color = $reasonColors[$reason] ?? 'white';
$label = $reasonLabels[$reason] ?? $reason;
$count = count($details);
$this->line(" <fg={$color};options=bold>{$label}</> — {$count} dòng:");
$showDetails = array_slice($details, 0, 30);
$rowNumbers = array_column($showDetails, 'row');
$codes = array_column($showDetails, 'code');
$chunks = array_chunk($rowNumbers, 15);
foreach ($chunks as $chunk) {
$this->line(" <fg={$color}>Rows: " . implode(', ', $chunk) . "</>");
}
if ($count > 30) {
$this->line(" <fg=gray>... và " . ($count - 30) . " dòng nữa</>");
}
$this->newLine();
}
$this->line('<fg=gray>Gợi ý: Các dòng "Empty customer name" thường là dòng trống trong Excel chỉ có mã căn hộ nhưng không có dữ liệu khác.</>');
$this->line('<fg=gray>Chúng được bỏ qua vì thiếu thông tin bắt buộc (Họ tên KH).</>');
}
}
}