nang cap san sang import
This commit is contained in:
446
app/Console/Commands/ImportCskh.php
Normal file
446
app/Console/Commands/ImportCskh.php
Normal 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],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/Enums/ContractStatus.php
Normal file
28
app/Enums/ContractStatus.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Enums/ContractType.php
Normal file
28
app/Enums/ContractType.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Enums/HandoverStatus.php
Normal file
31
app/Enums/HandoverStatus.php
Normal 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
28
app/Enums/ServiceType.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Filament/Resources/Contracts/ContractResource.php
Normal file
57
app/Filament/Resources/Contracts/ContractResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/Contracts/Pages/CreateContract.php
Normal file
11
app/Filament/Resources/Contracts/Pages/CreateContract.php
Normal 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;
|
||||
}
|
||||
19
app/Filament/Resources/Contracts/Pages/EditContract.php
Normal file
19
app/Filament/Resources/Contracts/Pages/EditContract.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Contracts/Pages/ListContracts.php
Normal file
19
app/Filament/Resources/Contracts/Pages/ListContracts.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Filament/Resources/Contracts/Schemas/ContractForm.php
Normal file
53
app/Filament/Resources/Contracts/Schemas/ContractForm.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
app/Filament/Resources/Contracts/Tables/ContractsTable.php
Normal file
67
app/Filament/Resources/Contracts/Tables/ContractsTable.php
Normal 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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
@@ -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
27
app/Models/Contract.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
21
app/Models/Handover.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
18
app/Models/ProductService.php
Normal file
18
app/Models/ProductService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
44
app/Policies/ContractPolicy.php
Normal file
44
app/Policies/ContractPolicy.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user