nang cap san sang import

This commit is contained in:
2026-05-01 04:33:00 +00:00
parent 6c74c17b48
commit 6ef2e9fe4e
93 changed files with 6542 additions and 12 deletions

View File

@@ -0,0 +1,446 @@
<?php
namespace App\Console\Commands;
use App\Models\Contract;
use App\Models\Customer;
use App\Models\CustomerProduct;
use App\Models\Feedback;
use App\Models\FeedbackInteraction;
use App\Models\Handover;
use App\Models\Product;
use App\Models\ProductService;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Spatie\SimpleExcel\SimpleExcelReader;
class ImportCskh extends Command
{
protected $signature = 'app:import-cskh
{file_path : Path to CSV/Excel file}
{--dry-run : Analyze only, do not write to database}';
protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational';
private int $statsProducts = 0;
private int $statsCustomers = 0;
private int $statsContracts = 0;
private int $statsHandovers = 0;
private int $statsServices = 0;
private int $statsFeedbacks = 0;
private int $statsInteractions = 0;
private int $statsSkipped = 0;
public function handle(): int
{
$filePath = $this->argument('file_path');
$dryRun = $this->option('dry-run');
if (! file_exists($filePath)) {
$this->error("File not found: {$filePath}");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('=== DRY RUN MODE — No data will be written ===');
}
$rows = SimpleExcelReader::create($filePath)->getRows();
$total = iterator_count($rows);
$rows = SimpleExcelReader::create($filePath)->getRows();
$this->info("Total rows in file: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$chunkSize = 100;
$chunk = [];
$rowCount = 0;
foreach ($rows as $row) {
$rowCount++;
if ($this->isMetaRow($row)) {
$this->statsSkipped++;
$bar->advance();
continue;
}
$chunk[] = $row;
if (count($chunk) >= $chunkSize) {
$this->processChunk($chunk, $dryRun);
$chunk = [];
}
$bar->advance();
}
if (! empty($chunk)) {
$this->processChunk($chunk, $dryRun);
}
$bar->finish();
$this->newLine(2);
$this->printSummary($rowCount, $dryRun);
return self::SUCCESS;
}
private function isMetaRow(array $row): bool
{
$code = $row['Mã căn hộ'] ?? '';
if (empty($code) || is_numeric($code)) {
return true;
}
if ($code === 'Ngày TT' || $code === 'Tình trạng' || $code === 'Tên DN') {
return true;
}
return false;
}
private function processChunk(array $rows, bool $dryRun): void
{
if ($dryRun) {
foreach ($rows as $row) {
$this->processRow($row, true);
}
return;
}
DB::transaction(function () use ($rows): void {
foreach ($rows as $row) {
$this->processRow($row, false);
}
});
}
private function processRow(array $row, bool $dryRun): void
{
$productCode = trim($row['Mã căn hộ'] ?? '');
$customerName = trim($row['Họ tên KH'] ?? '');
if (empty($productCode) || empty($customerName)) {
$this->statsSkipped++;
return;
}
if ($dryRun) {
$this->statsProducts++;
$this->statsCustomers++;
return;
}
$product = $this->findOrCreateProduct($productCode);
$customer = $this->findOrCreateCustomer($customerName);
$this->ensureCustomerProduct($product, $customer);
$this->processContracts($row, $product, $customer);
$this->processHandover($row, $product, $customer);
$this->processServices($row, $product);
$content = $this->extractContent($row);
if (! empty($content)) {
$this->processFeedback($content, $product, $customer);
}
}
private function findOrCreateProduct(string $code): Product
{
$p = Product::firstOrCreate(
['name' => $code],
['status' => 'active']
);
if ($p->wasRecentlyCreated) {
$this->statsProducts++;
}
return $p;
}
private function findOrCreateCustomer(string $name): Customer
{
$c = Customer::firstOrCreate(
['name' => $name],
['status' => 'active']
);
if ($c->wasRecentlyCreated) {
$this->statsCustomers++;
}
return $c;
}
private function ensureCustomerProduct(Product $product, Customer $customer): void
{
CustomerProduct::firstOrCreate([
'customer_id' => $customer->id,
'product_id' => $product->id,
]);
}
private function processContracts(array $row, Product $product, Customer $customer): void
{
$leaseValue = $row['HỢP ĐỒNG THUÊ'] ?? null;
$leaseStr = is_object($leaseValue) ? null : (string) ($leaseValue ?? '');
if (! empty($leaseStr) && ! is_numeric($leaseStr) && $leaseStr !== 'Tình trạng') {
$exists = Contract::where('product_id', $product->id)
->where('customer_id', $customer->id)
->where('type', 'lease')
->exists();
if (! $exists) {
Contract::create([
'product_id' => $product->id,
'customer_id' => $customer->id,
'type' => 'lease',
'status' => 'active',
]);
$this->statsContracts++;
}
}
$bccValue = $row['HĐ HỢP TÁC KINH DOANH'] ?? null;
$bccStr = is_object($bccValue) ? null : (string) ($bccValue ?? '');
if (! empty($bccStr) && ! is_numeric($bccStr) && $bccStr !== 'Tình trạng HĐ') {
$exists = Contract::where('product_id', $product->id)
->where('customer_id', $customer->id)
->where('type', 'bcc')
->exists();
if (! $exists) {
Contract::create([
'product_id' => $product->id,
'customer_id' => $customer->id,
'type' => 'bcc',
'status' => 'active',
'signed_status' => $bccStr,
]);
$this->statsContracts++;
}
}
}
private function processHandover(array $row, Product $product, Customer $customer): void
{
$handoverDate = $this->parseHandoverDate($row['BÀN GIÀO CĂN HỘ'] ?? null);
$statusNote = $row['TÌNH TRẠNG'] ?? null;
$statusStr = is_object($statusNote) ? null : $statusNote;
if (empty($handoverDate) && empty($statusStr)) {
return;
}
$exists = Handover::where('product_id', $product->id)
->where('customer_id', $customer->id)
->exists();
if ($exists) {
return;
}
Handover::create([
'product_id' => $product->id,
'customer_id' => $customer->id,
'handover_date' => $handoverDate,
'status' => $handoverDate ? 'da_ban_giao' : 'chua_du_dieu_kien',
'remark' => $this->truncateRemark($statusStr),
]);
$this->statsHandovers++;
}
private function processServices(array $row, Product $product): void
{
$selfBiz = $row['TỰ DOANH'] ?? null;
$selfBizStr = is_object($selfBiz) ? null : (string) ($selfBiz ?? '');
if (! empty($selfBizStr) && ! is_numeric($selfBizStr) && $selfBizStr !== 'Tên DN') {
$exists = ProductService::where('product_id', $product->id)
->where('service_type', 'self_business')
->exists();
if (! $exists) {
ProductService::create([
'product_id' => $product->id,
'service_type' => 'self_business',
'status' => 'active',
'remark' => $selfBizStr,
]);
$this->statsServices++;
}
}
}
private function extractContent(array $row): ?string
{
$sources = [
$row['Lịch Sử khách hàng'] ?? null,
$row['Nội dung'] ?? null,
$row['TÌNH TRẠNG'] ?? null,
];
foreach ($sources as $src) {
if (is_object($src)) {
continue;
}
if (! empty($src) && ! is_numeric($src)) {
return (string) $src;
}
}
return null;
}
private function processFeedback(string $content, Product $product, Customer $customer): void
{
$activeContract = Contract::where('product_id', $product->id)
->where('status', 'active')
->first();
$lines = array_values(array_filter(
explode("\n", str_replace("\r\n", "\n", $content)),
fn ($l) => trim($l) !== ''
));
if (empty($lines)) {
return;
}
$title = 'Ticket từ dữ liệu lịch sử';
$customerProduct = CustomerProduct::where('customer_id', $customer->id)
->where('product_id', $product->id)
->first();
$feedback = Feedback::firstOrCreate(
[
'customer_id' => $customer->id,
'customer_product_id' => $customerProduct?->id,
'title' => $title,
],
[
'content' => $content,
'status' => 'pending',
'contract_id' => $activeContract?->id,
'feedback_channel_id' => 1,
]
);
if ($feedback->wasRecentlyCreated) {
$this->statsFeedbacks++;
}
$defaultUser = User::first();
foreach ($lines as $line) {
$exists = FeedbackInteraction::where('feedback_id', $feedback->id)
->where('content', $line)
->exists();
if ($exists) {
continue;
}
FeedbackInteraction::create([
'feedback_id' => $feedback->id,
'user_id' => $defaultUser?->id,
'type' => 'note',
'content' => mb_strlen($line) > 500 ? mb_substr($line, 0, 500) : $line,
]);
$this->statsInteractions++;
}
}
/**
* Parse the PHP DateTime JSON format: {"date":"2021-06-09 00:00:00.000000","timezone_type":3,"timezone":"UTC"}
*/
private function parseHandoverDate(mixed $value): ?string
{
if (empty($value)) {
return null;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
}
if (is_object($value)) {
return null;
}
if (is_string($value) && str_starts_with($value, '{')) {
$decoded = json_decode($value, true);
if (is_array($decoded) && ! empty($decoded['date'])) {
$timestamp = strtotime($decoded['date']);
return $timestamp ? date('Y-m-d', $timestamp) : null;
}
return null;
}
return $this->parseDateString((string) $value);
}
private function parseDateString(string $value): ?string
{
$value = trim($value);
$patterns = [
'/^\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}$/',
'/^\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2}$/',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $value)) {
$cleaned = str_replace('/', '-', $value);
$timestamp = strtotime($cleaned);
return $timestamp ? date('Y-m-d', $timestamp) : null;
}
}
return null;
}
private function truncateRemark(?string $value): ?string
{
if (empty($value) || is_numeric($value)) {
return null;
}
return mb_strlen($value) > 500 ? mb_substr($value, 0, 500) : $value;
}
private function printSummary(int $totalRows, bool $dryRun): void
{
$mode = $dryRun ? '[DRY RUN] ' : '';
$this->table(
['Metric', 'Count'],
[
['Total rows in file', $totalRows],
['Skipped (meta/empty)', $this->statsSkipped],
[$mode . 'New Products', $this->statsProducts],
[$mode . 'New Customers', $this->statsCustomers],
[$mode . 'New Contracts', $this->statsContracts],
[$mode . 'New Handovers', $this->statsHandovers],
[$mode . 'New Services', $this->statsServices],
[$mode . 'New Feedbacks', $this->statsFeedbacks],
[$mode . 'New Interactions', $this->statsInteractions],
]
);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum ContractStatus: string
{
case ACTIVE = 'active';
case EXPIRED = 'expired';
case LIQUIDATED = 'liquidated';
public function label(): string
{
return match ($this) {
self::ACTIVE => 'Đang hiệu lực',
self::EXPIRED => 'Hết hạn',
self::LIQUIDATED => 'Đã thanh lý',
};
}
public function options(): array
{
return [
self::ACTIVE->value => self::ACTIVE->label(),
self::EXPIRED->value => self::EXPIRED->label(),
self::LIQUIDATED->value => self::LIQUIDATED->label(),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum ContractType: string
{
case SALE = 'sale';
case LEASE = 'lease';
case BCC = 'bcc';
public function label(): string
{
return match ($this) {
self::SALE => 'HĐ Mua bán',
self::LEASE => 'HĐ Thuê',
self::BCC => 'HĐ Hợp tác kinh doanh (BCC)',
};
}
public static function options(): array
{
return [
self::SALE->value => self::SALE->label(),
self::LEASE->value => self::LEASE->label(),
self::BCC->value => self::BCC->label(),
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Enums;
enum HandoverStatus: string
{
case NOT_READY = 'chua_du_dieu_kien';
case READY = 'du_dieu_kien';
case HANDED_OVER = 'da_ban_giao';
case SCHEDULED = 'da_len_lich';
public function label(): string
{
return match ($this) {
self::NOT_READY => 'Chưa đủ điều kiện',
self::READY => 'Đủ điều kiện',
self::HANDED_OVER => 'Đã bàn giao',
self::SCHEDULED => 'Đã lên lịch',
};
}
public function options(): array
{
return [
self::NOT_READY->value => self::NOT_READY->label(),
self::READY->value => self::READY->label(),
self::SCHEDULED->value => self::SCHEDULED->label(),
self::HANDED_OVER->value => self::HANDED_OVER->label(),
];
}
}

28
app/Enums/ServiceType.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum ServiceType: string
{
case MANAGEMENT_FEE = 'management_fee';
case GRATITUDE = 'gratitude';
case SELF_BUSINESS = 'self_business';
public function label(): string
{
return match ($this) {
self::MANAGEMENT_FEE => 'Phí quản lý',
self::GRATITUDE => 'Tri ân',
self::SELF_BUSINESS => 'Tự doanh',
};
}
public function options(): array
{
return [
self::MANAGEMENT_FEE->value => self::MANAGEMENT_FEE->label(),
self::GRATITUDE->value => self::GRATITUDE->label(),
self::SELF_BUSINESS->value => self::SELF_BUSINESS->label(),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\Contracts;
use App\Filament\Resources\Contracts\Pages\CreateContract;
use App\Filament\Resources\Contracts\Pages\EditContract;
use App\Filament\Resources\Contracts\Pages\ListContracts;
use App\Filament\Resources\Contracts\Schemas\ContractForm;
use App\Filament\Resources\Contracts\Tables\ContractsTable;
use App\Models\Contract;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class ContractResource extends Resource
{
protected static ?string $model = Contract::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
protected static ?string $pluralLabel = 'Contracts';
protected static ?int $navigationSort = 4;
public static function getNavigationGroup(): ?string
{
return 'Management';
}
public static function form(Schema $schema): Schema
{
return ContractForm::configure($schema);
}
public static function table(Table $table): Table
{
return ContractsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListContracts::route('/'),
'create' => CreateContract::route('/create'),
'edit' => EditContract::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Contracts\Pages;
use App\Filament\Resources\Contracts\ContractResource;
use Filament\Resources\Pages\CreateRecord;
class CreateContract extends CreateRecord
{
protected static string $resource = ContractResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Contracts\Pages;
use App\Filament\Resources\Contracts\ContractResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditContract extends EditRecord
{
protected static string $resource = ContractResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Contracts\Pages;
use App\Filament\Resources\Contracts\ContractResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListContracts extends ListRecords
{
protected static string $resource = ContractResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Filament\Resources\Contracts\Schemas;
use App\Enums\ContractStatus;
use App\Enums\ContractType;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class ContractForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Thông tin hợp đồng')
->schema([
Select::make('product_id')
->relationship('product', 'name')
->searchable()
->preload()
->required(),
Select::make('customer_id')
->relationship('customer', 'name')
->searchable()
->preload()
->required(),
Select::make('type')
->options(ContractType::options())
->required()
->native(false),
Select::make('status')
->options(ContractStatus::ACTIVE->options())
->default('active')
->required()
->native(false),
DatePicker::make('start_date')
->label('Ngày bắt đầu'),
DatePicker::make('end_date')
->label('Ngày kết thúc'),
DatePicker::make('printed_at')
->label('Ngày in HĐ'),
TextInput::make('signed_status')
->label('Tình trạng ký')
->maxLength(255),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Resources\Contracts\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ContractsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->sortable(),
TextColumn::make('product.name')
->searchable()
->sortable(),
TextColumn::make('customer.name')
->searchable()
->sortable(),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label()),
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'expired' => 'warning',
'liquidated' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => \App\Enums\ContractStatus::from($state)->label()),
TextColumn::make('start_date')
->date()
->sortable()
->toggleable(),
TextColumn::make('end_date')
->date()
->sortable()
->toggleable(),
TextColumn::make('signed_status')
->toggleable(),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
->label('Tickets'),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -47,6 +47,54 @@ class FeedbackForm
})
->visible(fn ($get): bool => ! $get('is_general'))
->nullable()
->searchable()
->live(),
Select::make('contract_id')
->label('Hợp đồng')
->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id')))
->options(function ($get): array {
$customerProductId = $get('customer_product_id');
if (! $customerProductId) {
return [];
}
$cp = \App\Models\CustomerProduct::find($customerProductId);
if (! $cp) {
return [];
}
return \App\Models\Contract::where('product_id', $cp->product_id)
->where('status', 'active')
->with('customer')
->get()
->mapWithKeys(fn ($c) => [
$c->id => sprintf('%s (%s) - %s',
\App\Enums\ContractType::from($c->type)->label(),
$c->customer->name,
$c->start_date?->format('d/m/Y') ?? 'N/A',
),
])
->toArray();
})
->afterStateHydrated(function ($component, $state, $get): void {
if (filled($state)) {
return;
}
$customerProductId = $get('customer_product_id');
if (! $customerProductId) {
return;
}
$cp = \App\Models\CustomerProduct::find($customerProductId);
if (! $cp) {
return;
}
$activeContract = \App\Models\Contract::where('product_id', $cp->product_id)
->where('status', 'active')
->first();
if ($activeContract) {
$component->state($activeContract->id);
}
})
->nullable()
->searchable(),
Select::make('feedback_channel_id')

View File

@@ -26,6 +26,18 @@ class FeedbackTable
->label('Product')
->placeholder('General')
->sortable(),
TextColumn::make('contract.type')
->label('Contract')
->badge()
->formatStateUsing(fn (?string $state): string => $state ? \App\Enums\ContractType::from($state)->label() : '-')
->color(fn (?string $state): string => match ($state) {
'sale' => 'success',
'lease' => 'info',
'bcc' => 'warning',
default => 'gray',
})
->placeholder('-')
->toggleable(),
TextColumn::make('feedbackChannel.name')
->label('Channel'),
TextColumn::make('tags.name')

View File

@@ -5,6 +5,9 @@ namespace App\Filament\Resources\Products;
use App\Filament\Resources\Products\Pages\CreateProduct;
use App\Filament\Resources\Products\Pages\EditProduct;
use App\Filament\Resources\Products\Pages\ListProducts;
use App\Filament\Resources\Products\RelationManagers\ContractsRelationManager;
use App\Filament\Resources\Products\RelationManagers\HandoversRelationManager;
use App\Filament\Resources\Products\RelationManagers\ProductServicesRelationManager;
use App\Filament\Resources\Products\Schemas\ProductForm;
use App\Filament\Resources\Products\Tables\ProductsTable;
use App\Models\Product;
@@ -42,7 +45,9 @@ class ProductResource extends Resource
public static function getRelations(): array
{
return [
//
ContractsRelationManager::class,
HandoversRelationManager::class,
ProductServicesRelationManager::class,
];
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Products\RelationManagers;
use App\Enums\ContractStatus;
use App\Enums\ContractType;
use App\Filament\Resources\Contracts\ContractResource;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class ContractsRelationManager extends RelationManager
{
protected static string $relationship = 'contracts';
protected static ?string $title = 'Contracts';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('type')
->columns([
TextColumn::make('type')
->badge()
->formatStateUsing(fn (string $state): string => ContractType::from($state)->label()),
TextColumn::make('customer.name')
->label('Customer')
->searchable(),
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'expired' => 'warning',
'liquidated' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => ContractStatus::from($state)->label()),
TextColumn::make('start_date')
->date('d/m/Y')
->sortable(),
TextColumn::make('end_date')
->date('d/m/Y')
->sortable(),
TextColumn::make('signed_status')
->label('Signed'),
TextColumn::make('feedbacks_count')
->counts('feedbacks')
->label('Tickets'),
])
->filters([
SelectFilter::make('type')
->options(ContractType::options()),
SelectFilter::make('status')
->options(ContractStatus::ACTIVE->options()),
])
->defaultSort('created_at', 'desc')
->headerActions([])
->recordActions([]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Resources\Products\RelationManagers;
use App\Enums\HandoverStatus;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class HandoversRelationManager extends RelationManager
{
protected static string $relationship = 'handovers';
protected static ?string $title = 'Handovers';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('status')
->columns([
TextColumn::make('customer.name')
->label('Customer')
->searchable()
->sortable(),
TextColumn::make('handover_date')
->label('Ngày BG')
->date('d/m/Y')
->sortable(),
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'da_ban_giao' => 'success',
'da_len_lich' => 'info',
'du_dieu_kien' => 'warning',
'chua_du_dieu_kien' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => HandoverStatus::from($state)->label()),
TextColumn::make('remark')
->label('Ghi chú')
->limit(50)
->toggleable(),
TextColumn::make('created_at')
->dateTime('d/m/Y')
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('status')
->options(HandoverStatus::NOT_READY->options()),
])
->defaultSort('handover_date', 'desc')
->headerActions([])
->recordActions([]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\Products\RelationManagers;
use App\Enums\ServiceType;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class ProductServicesRelationManager extends RelationManager
{
protected static string $relationship = 'productServices';
protected static ?string $title = 'Services';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('service_type')
->columns([
TextColumn::make('service_type')
->badge()
->color(fn (string $state): string => match ($state) {
'management_fee' => 'info',
'gratitude' => 'success',
'self_business' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => ServiceType::from($state)->label()),
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'pending' => 'warning',
'completed' => 'info',
'cancelled' => 'danger',
default => 'gray',
}),
TextColumn::make('actual_collection_date')
->label('Ngày thu')
->date('d/m/Y')
->sortable(),
TextColumn::make('remark')
->label('Ghi chú')
->limit(50)
->toggleable(),
TextColumn::make('created_at')
->dateTime('d/m/Y')
->sortable()
->toggleable(),
])
->filters([
SelectFilter::make('service_type')
->options(ServiceType::MANAGEMENT_FEE->options()),
SelectFilter::make('status')
->options([
'active' => 'Active',
'pending' => 'Pending',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
]),
])
->defaultSort('created_at', 'desc')
->headerActions([])
->recordActions([]);
}
}

27
app/Models/Contract.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['product_id', 'customer_id', 'type', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status'])]
class Contract extends Model
{
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class);
}
}

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['customer_id', 'customer_product_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
#[Fillable(['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
class Feedback extends Model
{
public function customer(): BelongsTo
@@ -21,6 +21,11 @@ class Feedback extends Model
return $this->belongsTo(CustomerProduct::class, 'customer_product_id');
}
public function contract(): BelongsTo
{
return $this->belongsTo(Contract::class);
}
public function feedbackChannel(): BelongsTo
{
return $this->belongsTo(FeedbackChannel::class);

21
app/Models/Handover.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['product_id', 'customer_id', 'handover_date', 'status', 'remark'])]
class Handover extends Model
{
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'description', 'status'])]
class Product extends Model
@@ -15,4 +16,19 @@ class Product extends Model
->withPivot('purchase_date')
->withTimestamps();
}
public function contracts(): HasMany
{
return $this->hasMany(Contract::class);
}
public function handovers(): HasMany
{
return $this->hasMany(Handover::class);
}
public function productServices(): HasMany
{
return $this->hasMany(ProductService::class);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['product_id', 'service_type', 'status', 'actual_collection_date', 'remark'])]
class ProductService extends Model
{
protected $table = 'product_services';
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\Contract;
use App\Models\User;
class ContractPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function view(User $user, Contract $contract): bool
{
return $user->hasRole(['admin', 'manager', 'staff']);
}
public function create(User $user): bool
{
return $user->hasRole(['admin', 'manager']);
}
public function update(User $user, Contract $contract): bool
{
return $user->hasRole(['admin', 'manager']);
}
public function delete(User $user, Contract $contract): bool
{
return $user->hasRole('admin');
}
public function restore(User $user, Contract $contract): bool
{
return $user->hasRole('admin');
}
public function forceDelete(User $user, Contract $contract): bool
{
return $user->hasRole('admin');
}
}