feat: granular permissions, export backup, user/role management
- 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:
@@ -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).</>');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user