diff --git a/AGENTS.md b/AGENTS.md index 36b16639..bbed2ea0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ minicrm/ │ │ ├── ImportCskh.php # app:import-cskh │ │ └── ExportBackup.php # app:export-backup │ ├── Enums/ +│ │ ├── ContractCategory.php # V2: ownership/business │ │ ├── ContractType.php │ │ ├── ContractStatus.php │ │ ├── HandoverStatus.php @@ -123,11 +124,10 @@ minicrm/ │ │ └── Middleware/HandleUploadErrors.php │ ├── Models/ │ │ ├── ActivityLog.php # NEW: Audit trail model -│ │ ├── Contract.php -│ │ ├── Customer.php -│ │ ├── CustomerProduct.php +│ │ ├── Contract.php # V2: +category, +representative_* +│ │ ├── Customer.php # V2: ownedProducts() qua contracts │ │ ├── Department.php -│ │ ├── Feedback.php +│ │ ├── Feedback.php # V2: bỏ customer_product_id │ │ ├── FeedbackAttachment.php │ │ ├── FeedbackChannel.php │ │ ├── FeedbackInteraction.php @@ -136,7 +136,7 @@ minicrm/ │ │ ├── Product.php │ │ ├── ProductService.php │ │ ├── TicketTransferLog.php -│ │ └── User.php # HasRoles (Spatie), isAdmin(), isManager() +│ │ └── User.php │ ├── Policies/ │ │ ├── ActivityLogPolicy.php # NEW │ │ ├── ContractPolicy.php @@ -192,7 +192,26 @@ minicrm/ | metadata | json nullable | Extra data | | created_at | timestamp | | -### departments +### contracts (V2: category + representative) +| Column | Type | Notes | +|--------|------|-------| +| id | bigint PK | | +| product_id | fk -> products | cascadeOnDelete | +| customer_id | fk -> customers | cascadeOnDelete | +| category | string | **(V2)** ownership \| business | +| type | string | **(V2)** deposit, purchase, transfer, rental, self_business, bcc | +| start_date, end_date | date nullable | | +| status | string | active, expired, liquidated | +| printed_at | date nullable | | +| signed_status | string nullable | | +| representative_name | string nullable | **(V2)** | +| representative_phone | string nullable | **(V2)** | +| representative_email | string nullable | **(V2)** | +| created_at, updated_at | timestamp | | + +> **V2 Constraint:** Mỗi product chỉ có tối đa 1 contract active mỗi category. Validation ở cả Model level (`booted::saving`) và UI. + +### departments (NEW) | Column | Type | Notes | |--------|------|-------| | id | bigint PK | | @@ -216,8 +235,7 @@ minicrm/ |--------|------|-------| | id | bigint PK | | | customer_id | fk -> customers | cascadeOnDelete | -| customer_product_id | fk -> customer_product nullable | null = general feedback | -| contract_id | fk -> contracts nullable | **(NEW)** Snapshot contract | +| contract_id | fk -> contracts nullable | **(V2)** Snapshot contract (thay thế customer_product_id) | | feedback_channel_id | fk -> feedback_channels | | | assigned_to | fk -> users nullable | Current handler | | current_department_id | fk -> departments nullable | Current department | @@ -244,7 +262,7 @@ minicrm/ | created_at, updated_at | timestamp | | ### Other tables -feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers, customer_product, feedback_channels, users, contracts, handovers, product_services, roles, permissions, model_has_roles (Spatie), notifications, sessions, cache +feedback_interactions, feedback_tags, feedback_feedback_tag, products, customers, feedback_channels, users, contracts, handovers, product_services, roles, permissions, model_has_roles (Spatie), notifications, sessions, cache ## Workflow diff --git a/CODEBASE_SNAPSHOT.md b/CODEBASE_SNAPSHOT.md index b76a2581..67987ba8 100644 --- a/CODEBASE_SNAPSHOT.md +++ b/CODEBASE_SNAPSHOT.md @@ -132,7 +132,8 @@ app/ │ ├── ImportCskh.php # app:import-cskh command │ └── ExportBackup.php # app:export-backup command (JSON backup) ├── Enums/ -│ ├── ContractType.php # SALE/LEASE/BCC +│ ├── ContractCategory.php # V2: ownership/business +│ ├── ContractType.php # V2: deposit/purchase/transfer/rental/self_business/bcc │ ├── ContractStatus.php # ACTIVE/EXPIRED/LIQUIDATED │ ├── HandoverStatus.php # NOT_READY/READY/SCHEDULED/HANDED_OVER │ └── ServiceType.php # MANAGEMENT_FEE/GRATITUDE/SELF_BUSINESS @@ -143,9 +144,8 @@ app/ │ └── HandleUploadErrors.php ├── Models/ │ ├── ActivityLog.php # NEW: Audit trail (user_id, action, description, metadata) -│ ├── Contract.php -│ ├── Customer.php -│ ├── CustomerProduct.php # pivot model (customer_product table) +│ ├── Contract.php # V2: +category, +representative_* +│ ├── Customer.php # V2: ownedProducts() qua contracts │ ├── Department.php │ ├── Feedback.php │ ├── FeedbackAttachment.php @@ -224,7 +224,7 @@ Relationships: `user()` BelongsTo | description | text nullable | | status | string (active/inactive/sold_out) | -Relationships: `customers()` BelongsToMany (pivot: customer_product, withPivot: purchase_date), `contracts()` HasMany, `handovers()` HasMany, `productServices()` HasMany +Relationships: `contracts()` HasMany, `handovers()` HasMany, `productServices()` HasMany ### ProductService (`product_services`) | Cột | Kiểu | @@ -250,22 +250,28 @@ Relationships: `product()` BelongsTo Relationships: `product()` BelongsTo, `customer()` BelongsTo -### Contract (`contracts`) +### Contract (`contracts`) — V2 | Cột | Kiểu | |-----|------| | id | bigint PK | | product_id | FK->products cascadeOnDelete | | customer_id | FK->customers cascadeOnDelete | -| type | string (sale/lease/bcc → ContractType enum) | +| category | string (ownership/business → ContractCategory enum) | +| type | string (deposit/purchase/transfer/rental/self_business/bcc → ContractType enum) | | start_date | date nullable | | end_date | date nullable | | status | string (active/expired/liquidated → ContractStatus enum) | | printed_at | date nullable | | signed_status | string nullable | +| representative_name | string nullable | +| representative_phone | string nullable | +| representative_email | string nullable | Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasMany -### Customer (`customers`) +> **Constraint:** Mỗi product tối đa 1 active contract mỗi category. Validation ở Model `booted::saving`. + +### Customer (`customers`) — V2 | Cột | Kiểu | |-----|------| | id | bigint PK | @@ -275,18 +281,7 @@ Relationships: `product()` BelongsTo, `customer()` BelongsTo, `feedbacks()` HasM | address | text nullable | | status | string (active) | -Relationships: `products()` BelongsToMany, `feedbacks()` HasMany - -### CustomerProduct (`customer_product` — pivot model) -| Cột | Kiểu | -|-----|------| -| id | bigint PK | -| customer_id | FK->customers cascadeOnDelete | -| product_id | FK->products cascadeOnDelete | -| purchase_date | date nullable | -UNIQUE(customer_id, product_id) - -Relationships: `customer()` BelongsTo, `product()` BelongsTo, `feedbacks()` HasMany +Relationships: `ownedProducts()` BelongsToMany qua contracts (ownership + active), `feedbacks()` HasMany ### Department (`departments`) | Cột | Kiểu | @@ -308,12 +303,11 @@ Relationships: `manager()` BelongsTo User, `feedbacks()` HasMany, `transferLogsF Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback, `activityLogs()` HasMany. -### Feedback (`feedback`) +### Feedback (`feedback`) — V2 | Cột | Kiểu | |-----|------| | id | bigint PK | | customer_id | FK->customers cascadeOnDelete | -| customer_product_id | FK->customer_product nullable nullOnDelete | | contract_id | FK->contracts nullable nullOnDelete | | feedback_channel_id | FK->feedback_channels | | assigned_to | FK->users nullable nullOnDelete | @@ -323,9 +317,9 @@ Uses Spatie `HasRoles`. Relationships: `assignedFeedbacks()` HasMany Feedback, ` | content | text | | status | string (pending/processing/resolved/closed) | -Fillable: `['customer_id', 'customer_product_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated']` +Fillable: `['customer_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated']` -Relationships (10): `customer()`, `customerProduct()`, `contract()`, `feedbackChannel()`, `assignedTo()`, `currentDepartment()`, `transferLogs()`, `interactions()` ordered latest, `attachments()`, `tags()` BelongsToMany +Relationships: `customer()`, `contract()`, `feedbackChannel()`, `assignedTo()`, `currentDepartment()`, `transferLogs()`, `interactions()` ordered latest, `attachments()`, `tags()` BelongsToMany ### FeedbackInteraction (`feedback_interactions`) | Cột | Kiểu | @@ -498,6 +492,7 @@ Dashboard (3 custom widgets) | 23 | `2026_05_01_000003_create_product_services_table` | product_services | | 24 | `2026_05_01_000004_add_color_to_feedback_channels_table` | (modify feedback_channels) add color | | 25 | `2026_05_09_104519_create_activity_logs_table` | activity_logs | +| 26 | `2026_05_12_075014_v2_restructure_contracts` | **(V2)** contracts +category, +representative; drop customer_product; drop feedback.customer_product_id | --- diff --git a/TASKS_ROADMAP.md b/TASKS_ROADMAP.md index d84646e2..06465539 100644 --- a/TASKS_ROADMAP.md +++ b/TASKS_ROADMAP.md @@ -9,6 +9,7 @@ - **SOP Phase 1-8:** 100% hoàn thành - **AI Instructions Core:** 85% (6/7, còn auto-suggest transfer) +- **V2 Restructure:** 100% hoàn thành (Contract category, bỏ pivot, đại diện) - **Test:** Pest 8/8 tests (21 assertions) pass - **Data import:** 3658 rows từ `ai_instructions/CSKH.xlsx` imported successfully - **Dual Panel:** Admin (đầy đủ) + Support (rút gọn, tập trung feedback) @@ -146,6 +147,20 @@ - [x] Resources trong Support: Feedback, Customer, Product, FeedbackChannel, FeedbackTag, ActivityLog - [x] Widgets: ResolvedTicketsWidget, AvgProcessingTimeWidget, HandlerPerformanceWidget +### V2: Contract Restructure — Category + Representative + Bỏ Pivot +- [x] Migration: thêm `category` (ownership/business), `representative_*` vào contracts, xóa `customer_product` +- [x] Enum: `ContractCategory` (ownership, business) + cập nhật `ContractType` (deposit, purchase, transfer, rental, self_business, bcc) +- [x] Model: Contract (+category, +representative), Customer (ownedProducts qua contracts), Feedback (-customer_product_id) +- [x] Xóa `CustomerProduct` model/pivot +- [x] Validation: ràng buộc 1 active/category/product (CreateContract/EditContract) +- [x] FeedbackForm: filter mới (Customer → Product qua Contract ownership + đại diện), case-insensitive search +- [x] ContractForm: select category → dynamic type options, representative form section +- [x] FeedbackTable: `contract.product.name` thay `customerProduct.product.name` +- [x] Seeder: 6 contracts V2 (ownership+business, có representative), feedback no customer_product_id +- [x] ImportCskh: cập nhật pipeline, không còn customer_product +- [x] i18n: vi/en cho category, type mới, representative, validation +- [x] Tests: 8/8 pass (21 assertions) + ### ActivityLog — Audit Trail - [x] Migration `activity_logs`: user_id, action, description, subject_type/id, metadata - [x] Model `ActivityLog` + Service `ActivityLogger` (static log method) diff --git a/app/Console/Commands/ImportCskh.php b/app/Console/Commands/ImportCskh.php index 67fbf73d..ce676106 100644 --- a/app/Console/Commands/ImportCskh.php +++ b/app/Console/Commands/ImportCskh.php @@ -5,7 +5,6 @@ namespace App\Console\Commands; use App\Services\ActivityLogger; use App\Models\Contract; use App\Models\Customer; -use App\Models\CustomerProduct; use App\Models\Feedback; use App\Models\FeedbackInteraction; use App\Models\Handover; @@ -22,7 +21,7 @@ class ImportCskh extends Command {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'; + protected $description = 'Import dữ liệu CSKH từ file Excel/CSV vào database relational (V2)'; private int $statsProducts = 0; private int $statsCustomers = 0; @@ -123,11 +122,6 @@ class ImportCskh extends Command return self::SUCCESS; } - private function isMetaRow(array $row): bool - { - return $this->getMetaRowReason($row) !== null; - } - private function getMetaRowReason(array $row): ?string { $code = $row['Mã căn hộ'] ?? ''; @@ -210,8 +204,6 @@ class ImportCskh extends Command $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); @@ -250,14 +242,6 @@ class ImportCskh extends Command 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; @@ -265,14 +249,16 @@ class ImportCskh extends Command 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') + ->where('category', 'business') + ->where('type', 'rental') ->exists(); if (! $exists) { Contract::create([ 'product_id' => $product->id, 'customer_id' => $customer->id, - 'type' => 'lease', + 'category' => 'business', + 'type' => 'rental', 'status' => 'active', ]); $this->statsContracts++; @@ -284,6 +270,7 @@ class ImportCskh extends Command 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('category', 'business') ->where('type', 'bcc') ->exists(); @@ -291,6 +278,7 @@ class ImportCskh extends Command Contract::create([ 'product_id' => $product->id, 'customer_id' => $customer->id, + 'category' => 'business', 'type' => 'bcc', 'status' => 'active', 'signed_status' => $bccStr, @@ -386,20 +374,15 @@ class ImportCskh extends Command $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, + 'contract_id' => $activeContract?->id, 'title' => $title, ], [ 'content' => $content, 'status' => 'pending', - 'contract_id' => $activeContract?->id, 'feedback_channel_id' => 1, ] ); @@ -429,9 +412,6 @@ class ImportCskh extends Command } } - /** - * 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)) { diff --git a/app/Enums/ContractCategory.php b/app/Enums/ContractCategory.php new file mode 100644 index 00000000..31c5059d --- /dev/null +++ b/app/Enums/ContractCategory.php @@ -0,0 +1,39 @@ + __('enums.contract_category.ownership'), + self::BUSINESS => __('enums.contract_category.business'), + }; + } + + public function color(): string + { + return match ($this) { + self::OWNERSHIP => 'success', + self::BUSINESS => 'info', + }; + } + + public static function options(): array + { + return collect(self::cases())->mapWithKeys(fn ($case) => [ + $case->value => $case->label(), + ])->toArray(); + } +} diff --git a/app/Enums/ContractType.php b/app/Enums/ContractType.php index be4fd58f..b488bda2 100644 --- a/app/Enums/ContractType.php +++ b/app/Enums/ContractType.php @@ -3,24 +3,33 @@ namespace App\Enums; /** - * Contract Type Enum + * Contract Type Enum — Loại hợp đồng chi tiết (subtype của category) * - * Để thêm loại hợp đồng mới: - * 1. Thêm case mới vào enum + * Ownership: deposit (đặt cọc), purchase (mua bán), transfer (chuyển nhượng) + * Business: rental (cho thuê), self_business (tự doanh), bcc (hợp tác KD) + * + * Để thêm loại mới: + * 1. Thêm case mới vào enum + gán category phù hợp trong category() * 2. Thêm label trong lang/vi/enums.php và lang/en/enums.php * 3. Reload browser */ enum ContractType: string { - case SALE = 'sale'; - case LEASE = 'lease'; + case DEPOSIT = 'deposit'; + case PURCHASE = 'purchase'; + case TRANSFER = 'transfer'; + case RENTAL = 'rental'; + case SELF_BUSINESS = 'self_business'; case BCC = 'bcc'; public function label(): string { return match ($this) { - self::SALE => __('enums.contract_type.sale'), - self::LEASE => __('enums.contract_type.lease'), + self::DEPOSIT => __('enums.contract_type.deposit'), + self::PURCHASE => __('enums.contract_type.purchase'), + self::TRANSFER => __('enums.contract_type.transfer'), + self::RENTAL => __('enums.contract_type.rental'), + self::SELF_BUSINESS => __('enums.contract_type.self_business'), self::BCC => __('enums.contract_type.bcc'), }; } @@ -28,9 +37,20 @@ enum ContractType: string public function color(): string { return match ($this) { - self::SALE => 'success', - self::LEASE => 'info', - self::BCC => 'warning', + self::DEPOSIT => 'warning', + self::PURCHASE => 'success', + self::TRANSFER => 'info', + self::RENTAL => 'info', + self::SELF_BUSINESS => 'warning', + self::BCC => 'danger', + }; + } + + public function category(): ContractCategory + { + return match ($this) { + self::DEPOSIT, self::PURCHASE, self::TRANSFER => ContractCategory::OWNERSHIP, + self::RENTAL, self::SELF_BUSINESS, self::BCC => ContractCategory::BUSINESS, }; } @@ -40,4 +60,14 @@ enum ContractType: string $case->value => $case->label(), ])->toArray(); } + + public static function ownershipCases(): array + { + return [self::DEPOSIT, self::PURCHASE, self::TRANSFER]; + } + + public static function businessCases(): array + { + return [self::RENTAL, self::SELF_BUSINESS, self::BCC]; + } } diff --git a/app/Filament/Resources/Contracts/ContractResource.php b/app/Filament/Resources/Contracts/ContractResource.php index bdf3fb75..8c9cd445 100644 --- a/app/Filament/Resources/Contracts/ContractResource.php +++ b/app/Filament/Resources/Contracts/ContractResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Contracts; +use App\Enums\ContractCategory; use App\Filament\Resources\Contracts\Pages\CreateContract; use App\Filament\Resources\Contracts\Pages\EditContract; use App\Filament\Resources\Contracts\Pages\ListContracts; @@ -64,7 +65,6 @@ class ContractResource extends Resource ]; } - // GlobalSearch customization public static function getGlobalSearchEloquentQuery(): Builder { return parent::getGlobalSearchEloquentQuery() @@ -89,6 +89,7 @@ class ContractResource extends Resource return [ __('app.product') => $record->product?->name ?? '-', __('app.widget_customer') => $record->customer?->name ?? '-', + __('enums.contract_category_label') => ContractCategory::from($record->category)->label(), __('app.blade_status') => \App\Enums\ContractStatus::from($record->status)->label(), __('app.signed_status') => $record->signed_status ?? '-', ]; diff --git a/app/Filament/Resources/Contracts/Pages/CreateContract.php b/app/Filament/Resources/Contracts/Pages/CreateContract.php index 98522045..d06ea025 100644 --- a/app/Filament/Resources/Contracts/Pages/CreateContract.php +++ b/app/Filament/Resources/Contracts/Pages/CreateContract.php @@ -3,9 +3,38 @@ namespace App\Filament\Resources\Contracts\Pages; use App\Filament\Resources\Contracts\ContractResource; +use App\Models\Contract; +use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; class CreateContract extends CreateRecord { protected static string $resource = ContractResource::class; + + protected function mutateFormDataBeforeCreate(array $data): array + { + if (($data['status'] ?? '') === 'active') { + $existing = Contract::where('product_id', $data['product_id']) + ->where('category', $data['category']) + ->where('status', 'active') + ->first(); + + if ($existing) { + Notification::make() + ->title(__('feedback.contract_duplicate_title', [ + 'category' => \App\Enums\ContractCategory::from($data['category'])->label(), + ])) + ->body(__('feedback.contract_duplicate_body', [ + 'product' => $existing->product?->name ?? '?', + 'customer' => $existing->customer?->name ?? '?', + ])) + ->warning() + ->send(); + + $this->halt(); + } + } + + return $data; + } } diff --git a/app/Filament/Resources/Contracts/Pages/EditContract.php b/app/Filament/Resources/Contracts/Pages/EditContract.php index 4e920bde..4adb8cef 100644 --- a/app/Filament/Resources/Contracts/Pages/EditContract.php +++ b/app/Filament/Resources/Contracts/Pages/EditContract.php @@ -3,7 +3,9 @@ namespace App\Filament\Resources\Contracts\Pages; use App\Filament\Resources\Contracts\ContractResource; +use App\Models\Contract; use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; class EditContract extends EditRecord @@ -16,4 +18,32 @@ class EditContract extends EditRecord DeleteAction::make(), ]; } + + protected function mutateFormDataBeforeSave(array $data): array + { + if (($data['status'] ?? '') === 'active') { + $existing = Contract::where('product_id', $data['product_id']) + ->where('category', $data['category']) + ->where('status', 'active') + ->where('id', '!=', $this->getRecord()->id) + ->first(); + + if ($existing) { + Notification::make() + ->title(__('feedback.contract_duplicate_title', [ + 'category' => \App\Enums\ContractCategory::from($data['category'])->label(), + ])) + ->body(__('feedback.contract_duplicate_body', [ + 'product' => $existing->product?->name ?? '?', + 'customer' => $existing->customer?->name ?? '?', + ])) + ->warning() + ->send(); + + $this->halt(); + } + } + + return $data; + } } diff --git a/app/Filament/Resources/Contracts/Schemas/ContractForm.php b/app/Filament/Resources/Contracts/Schemas/ContractForm.php index dfe248c4..ce0d2c19 100644 --- a/app/Filament/Resources/Contracts/Schemas/ContractForm.php +++ b/app/Filament/Resources/Contracts/Schemas/ContractForm.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Contracts\Schemas; +use App\Enums\ContractCategory; use App\Enums\ContractStatus; use App\Enums\ContractType; use Filament\Forms\Components\DatePicker; @@ -16,6 +17,36 @@ class ContractForm { return $schema ->components([ + Section::make(__('app.contract_category_section')) + ->schema([ + Select::make('category') + ->label(__('enums.contract_category_label')) + ->options(ContractCategory::options()) + ->required() + ->native(false) + ->live(), + Select::make('type') + ->label(__('enums.contract_type_label')) + ->options(function ($get): array { + $category = $get('category'); + if ($category === 'ownership') { + return collect(ContractType::ownershipCases())->mapWithKeys(fn ($c) => [ + $c->value => $c->label(), + ])->toArray(); + } + if ($category === 'business') { + return collect(ContractType::businessCases())->mapWithKeys(fn ($c) => [ + $c->value => $c->label(), + ])->toArray(); + } + + return ContractType::options(); + }) + ->required() + ->native(false), + ]) + ->columns(2), + Section::make(__('app.contract_info')) ->schema([ Select::make('product_id') @@ -28,11 +59,6 @@ class ContractForm ->relationship('customer', 'name') ->searchable() ->required(), - Select::make('type') - ->label(__('app.contract')) - ->options(ContractType::options()) - ->required() - ->native(false), Select::make('status') ->label(__('app.status')) ->options(ContractStatus::ACTIVE->options()) @@ -50,6 +76,22 @@ class ContractForm ->maxLength(255), ]) ->columns(2), + + Section::make(__('app.representative_info')) + ->description(__('app.representative_desc')) + ->schema([ + TextInput::make('representative_name') + ->label(__('app.name')) + ->maxLength(255), + TextInput::make('representative_phone') + ->label(__('app.phone')) + ->maxLength(20), + TextInput::make('representative_email') + ->label(__('app.email')) + ->email() + ->maxLength(255), + ]) + ->columns(3), ]); } } diff --git a/app/Filament/Resources/Contracts/Tables/ContractsTable.php b/app/Filament/Resources/Contracts/Tables/ContractsTable.php index 4c8a1eb3..dde13606 100644 --- a/app/Filament/Resources/Contracts/Tables/ContractsTable.php +++ b/app/Filament/Resources/Contracts/Tables/ContractsTable.php @@ -2,10 +2,12 @@ namespace App\Filament\Resources\Contracts\Tables; +use App\Enums\ContractCategory; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; class ContractsTable @@ -17,15 +19,25 @@ class ContractsTable TextColumn::make('id') ->sortable(), TextColumn::make('product.name') + ->label(__('app.product')) ->searchable() ->sortable(), TextColumn::make('customer.name') + ->label(__('app.widget_customer')) ->searchable() ->sortable(), - TextColumn::make('type') + TextColumn::make('category') + ->label(__('enums.contract_category_label')) ->badge() - ->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label()), + ->formatStateUsing(fn (string $state): string => ContractCategory::from($state)->label()) + ->color(fn (string $state): string => ContractCategory::from($state)->color()), + TextColumn::make('type') + ->label(__('enums.contract_type_label')) + ->badge() + ->formatStateUsing(fn (string $state): string => \App\Enums\ContractType::from($state)->label()) + ->color(fn (string $state): string => \App\Enums\ContractType::from($state)->color()), TextColumn::make('status') + ->label(__('app.status')) ->badge() ->color(fn (string $state): string => match ($state) { 'active' => 'success', @@ -34,15 +46,22 @@ class ContractsTable default => 'gray', }) ->formatStateUsing(fn (string $state): string => \App\Enums\ContractStatus::from($state)->label()), + TextColumn::make('representative_name') + ->label(__('app.representative')) + ->placeholder('-') + ->toggleable(), TextColumn::make('start_date') + ->label(__('app.start_date')) ->date() ->sortable() ->toggleable(), TextColumn::make('end_date') + ->label(__('app.end_date')) ->date() ->sortable() ->toggleable(), TextColumn::make('signed_status') + ->label(__('app.signed_status')) ->toggleable(), TextColumn::make('feedbacks_count') ->counts('feedbacks') @@ -53,7 +72,12 @@ class ContractsTable ->toggleable(), ]) ->filters([ - // + SelectFilter::make('category') + ->label(__('enums.contract_category_label')) + ->options(ContractCategory::options()), + SelectFilter::make('status') + ->label(__('app.status')) + ->options(\App\Enums\ContractStatus::ACTIVE->options()), ]) ->recordActions([ EditAction::make(), diff --git a/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php b/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php index 59f2e81b..ab50c6b8 100644 --- a/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php +++ b/app/Filament/Resources/Customers/RelationManagers/FeedbacksRelationManager.php @@ -23,12 +23,14 @@ class FeedbacksRelationManager extends RelationManager ->recordTitleAttribute('title') ->columns([ TextColumn::make('title') + ->label(__('app.blade_title')) ->searchable() ->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])), - TextColumn::make('customerProduct.product.name') + TextColumn::make('contract.product.name') ->label(__('app.product')) ->placeholder(__('app.general')), TextColumn::make('status') + ->label(__('app.status')) ->badge() ->color(fn (string $state): string => match ($state) { 'pending' => 'warning', diff --git a/app/Filament/Resources/Customers/Schemas/CustomerForm.php b/app/Filament/Resources/Customers/Schemas/CustomerForm.php index 3cef8083..7f5a9d37 100644 --- a/app/Filament/Resources/Customers/Schemas/CustomerForm.php +++ b/app/Filament/Resources/Customers/Schemas/CustomerForm.php @@ -41,15 +41,6 @@ class CustomerForm ]) ->columns(2), - Section::make(__('app.owned_products')) - ->schema([ - Select::make('products') - ->label(__('app.resource_products')) - ->relationship('products', 'name') - ->multiple() - ->preload() - ->columnSpanFull(), - ]), ]); } } diff --git a/app/Filament/Resources/Customers/Tables/CustomersTable.php b/app/Filament/Resources/Customers/Tables/CustomersTable.php index 3263f72f..db87c03d 100644 --- a/app/Filament/Resources/Customers/Tables/CustomersTable.php +++ b/app/Filament/Resources/Customers/Tables/CustomersTable.php @@ -31,9 +31,6 @@ class CustomersTable 'inactive' => 'gray', default => 'gray', }), - TextColumn::make('products_count') - ->counts('products') - ->label(__('app.resource_products')), TextColumn::make('feedbacks_count') ->counts('feedbacks') ->label(__('app.feedbacks')), diff --git a/app/Filament/Resources/Feedback/FeedbackResource.php b/app/Filament/Resources/Feedback/FeedbackResource.php index 396621a4..8cffc4bb 100644 --- a/app/Filament/Resources/Feedback/FeedbackResource.php +++ b/app/Filament/Resources/Feedback/FeedbackResource.php @@ -65,11 +65,10 @@ class FeedbackResource extends Resource ]; } - // GlobalSearch customization public static function getGlobalSearchEloquentQuery(): Builder { return parent::getGlobalSearchEloquentQuery() - ->with(['customer', 'customerProduct.product', 'currentDepartment']); + ->with(['customer', 'contract.product', 'currentDepartment']); } public static function modifyGlobalSearchQuery(Builder $query, string $search): void @@ -83,7 +82,7 @@ class FeedbackResource extends Resource { return [ __('app.widget_customer') => $record->customer?->name ?? '-', - __('app.product') => $record->customerProduct?->product?->name ?? __('app.general'), + __('app.product') => $record->contract?->product?->name ?? __('app.general'), __('app.blade_status') => match ($record->status) { 'pending' => __('app.status_pending'), 'processing' => __('app.status_processing'), diff --git a/app/Filament/Resources/Feedback/Pages/CreateFeedback.php b/app/Filament/Resources/Feedback/Pages/CreateFeedback.php index 6ff2620e..6b37b1ee 100644 --- a/app/Filament/Resources/Feedback/Pages/CreateFeedback.php +++ b/app/Filament/Resources/Feedback/Pages/CreateFeedback.php @@ -15,14 +15,10 @@ class CreateFeedback extends CreateRecord protected function mutateFormDataBeforeCreate(array $data): array { - if (($data['is_general'] ?? false) === true) { - $data['customer_product_id'] = null; - } - $this->pendingAttachments = $data['attachments'] ?? []; $this->pendingCollection = $data['attachment_collection'] ?? 'general'; - unset($data['is_general'], $data['attachment_collection'], $data['attachments']); + unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']); return $data; } diff --git a/app/Filament/Resources/Feedback/Pages/EditFeedback.php b/app/Filament/Resources/Feedback/Pages/EditFeedback.php index 02a0c882..d63b5d8b 100644 --- a/app/Filament/Resources/Feedback/Pages/EditFeedback.php +++ b/app/Filament/Resources/Feedback/Pages/EditFeedback.php @@ -66,7 +66,7 @@ class EditFeedback extends EditRecord ->disk('public') ->directory('feedback-attachments') ->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime']) - ->maxSize(51200) // 50MB in KB + ->maxSize(51200) ->columnSpanFull(), ]) ->action(function (array $data): void { @@ -166,14 +166,10 @@ class EditFeedback extends EditRecord protected function mutateFormDataBeforeSave(array $data): array { - if (($data['is_general'] ?? false) === true) { - $data['customer_product_id'] = null; - } - $this->pendingAttachments = $data['attachments'] ?? []; $this->pendingCollection = $data['attachment_collection'] ?? 'general'; - unset($data['is_general'], $data['attachment_collection'], $data['attachments']); + unset($data['is_general'], $data['attachment_collection'], $data['attachments'], $data['product_id_virtual']); $newStatus = $data['status'] ?? null; if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') { @@ -235,7 +231,7 @@ class EditFeedback extends EditRecord protected function mutateFormDataBeforeFill(array $data): array { - $data['is_general'] = $data['customer_product_id'] === null; + $data['is_general'] = $data['contract_id'] === null; return $data; } diff --git a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php index dff43580..77278efb 100644 --- a/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php +++ b/app/Filament/Resources/Feedback/Schemas/FeedbackForm.php @@ -2,7 +2,7 @@ namespace App\Filament\Resources\Feedback\Schemas; -use App\Models\CustomerProduct; +use App\Models\Contract; use App\Models\Department; use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\FileUpload; @@ -25,6 +25,7 @@ class FeedbackForm ->icon('heroicon-o-user-group') ->schema([ Select::make('customer_id') + ->label(__('app.widget_customer')) ->relationship('customer', 'name') ->searchable() ->required() @@ -37,48 +38,51 @@ class FeedbackForm ->live() ->columnSpan(1), - Select::make('customer_product_id') + Select::make('product_id_virtual') ->label(__('app.product')) ->searchable() + ->options(fn ($get): array => self::getProductOptionsForCustomer($get('customer_id'))) ->getSearchResultsUsing(function (string $search, $get): array { $customerId = $get('customer_id'); if (! $customerId) { return []; } $keyword = mb_strtolower($search); - return CustomerProduct::where('customer_id', $customerId) - ->whereHas('product', fn ($q) => $q->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"])) - ->with('product') + $allIds = self::getProductIdsForCustomer($customerId); + + if ($allIds->isEmpty()) { + return []; + } + + return \App\Models\Product::whereIn('id', $allIds) + ->whereRaw('LOWER(name) LIKE ?', ["%{$keyword}%"]) ->limit(50) - ->get() - ->pluck('product.name', 'id') + ->pluck('name', 'id') ->toArray(); }) ->getOptionLabelUsing(fn ($value): ?string => - CustomerProduct::find($value)?->product?->name + \App\Models\Product::find($value)?->name ) ->visible(fn ($get): bool => ! $get('is_general')) ->nullable() - ->live(), + ->live() + ->columnSpan(1), Select::make('contract_id') ->label(__('app.contract')) - ->visible(fn ($get): bool => ! $get('is_general') && filled($get('customer_product_id'))) + ->visible(fn ($get): bool => ! $get('is_general') && filled($get('product_id_virtual'))) ->options(function ($get): array { - $customerProductId = $get('customer_product_id'); - if (! $customerProductId) { + $productId = $get('product_id_virtual'); + if (! $productId) { return []; } - $cp = \App\Models\CustomerProduct::find($customerProductId); - if (! $cp) { - return []; - } - return \App\Models\Contract::where('product_id', $cp->product_id) + + return Contract::where('product_id', $productId) ->where('status', 'active') ->with('customer') ->get() ->mapWithKeys(fn ($c) => [ - $c->id => sprintf('%s (%s) - %s', + $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', @@ -90,15 +94,11 @@ class FeedbackForm if (filled($state)) { return; } - $customerProductId = $get('customer_product_id'); - if (! $customerProductId) { + $productId = $get('product_id_virtual'); + if (! $productId) { return; } - $cp = \App\Models\CustomerProduct::find($customerProductId); - if (! $cp) { - return; - } - $activeContract = \App\Models\Contract::where('product_id', $cp->product_id) + $activeContract = Contract::where('product_id', $productId) ->where('status', 'active') ->first(); if ($activeContract) { @@ -115,17 +115,20 @@ class FeedbackForm ->icon('heroicon-o-chat-bubble-left-right') ->schema([ Select::make('feedback_channel_id') + ->label(__('app.channel')) ->relationship('feedbackChannel', 'name') ->required() ->preload() ->columnSpan(1), TextInput::make('title') + ->label(__('app.blade_title')) ->required() ->maxLength(255) ->columnSpan(1), Textarea::make('content') + ->label(__('app.description')) ->required() ->rows(6) ->columnSpanFull(), @@ -136,9 +139,9 @@ class FeedbackForm ->preload() ->columnSpanFull() ->createOptionForm([ - TextInput::make('name')->required(), - TextInput::make('slug')->required(), - ColorPicker::make('color')->default('#3b82f6'), + TextInput::make('name')->label(__('app.name'))->required(), + TextInput::make('slug')->label(__('app.slug'))->required(), + ColorPicker::make('color')->label(__('app.color'))->default('#3b82f6'), ]), ]) ->columns(2), @@ -148,6 +151,7 @@ class FeedbackForm ->icon('heroicon-o-arrow-path-rounded-square') ->schema([ Select::make('status') + ->label(__('app.status')) ->options([ 'pending' => __('app.status_pending'), 'processing' => __('app.status_processing'), @@ -159,6 +163,7 @@ class FeedbackForm ->columnSpan(1), Select::make('assigned_to') + ->label(__('app.assigned_to')) ->relationship('assignedTo', 'name') ->searchable() ->nullable() @@ -198,10 +203,42 @@ class FeedbackForm ->disk('public') ->directory('feedback-attachments') ->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'video/quicktime']) - ->maxSize(51200) // 50MB in KB + ->maxSize(51200) ->columnSpan(1), ]) ->columns(2), ]); } + + private static function getProductOptionsForCustomer(?int $customerId): array + { + $ids = self::getProductIdsForCustomer($customerId); + + return \App\Models\Product::whereIn('id', $ids) + ->limit(100) + ->pluck('name', 'id') + ->toArray(); + } + + private static function getProductIdsForCustomer(?int $customerId): \Illuminate\Support\Collection + { + if (! $customerId) { + return collect(); + } + + $ownedIds = Contract::where('customer_id', $customerId) + ->where('category', 'ownership') + ->where('status', 'active') + ->pluck('product_id'); + + $customer = \App\Models\Customer::find($customerId); + $repIds = collect(); + if ($customer?->email) { + $repIds = Contract::where('representative_email', $customer->email) + ->where('status', 'active') + ->pluck('product_id'); + } + + return $ownedIds->merge($repIds)->unique(); + } } diff --git a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php index b7bbe19c..bc6ea726 100644 --- a/app/Filament/Resources/Feedback/Tables/FeedbackTable.php +++ b/app/Filament/Resources/Feedback/Tables/FeedbackTable.php @@ -23,16 +23,18 @@ class FeedbackTable ->color('primary'), TextColumn::make('title') + ->label(__('app.blade_title')) ->searchable() ->sortable() ->weight('semibold') ->limit(40), TextColumn::make('customer.name') + ->label(__('app.widget_customer')) ->searchable() ->sortable(), - TextColumn::make('customerProduct.product.name') + TextColumn::make('contract.product.name') ->label(__('app.product')) ->placeholder(__('app.general')) ->sortable() @@ -43,9 +45,12 @@ class FeedbackTable ->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', + 'deposit' => 'warning', + 'purchase' => 'success', + 'transfer' => 'info', + 'rental' => 'info', + 'self_business' => 'warning', + 'bcc' => 'danger', default => 'gray', }) ->placeholder('-') @@ -62,6 +67,7 @@ class FeedbackTable ->toggleable(), TextColumn::make('status') + ->label(__('app.status')) ->badge() ->color(fn(string $state): string => match ($state) { 'pending' => 'warning', diff --git a/app/Filament/Resources/Products/Tables/ProductsTable.php b/app/Filament/Resources/Products/Tables/ProductsTable.php index 58865f99..b8f51430 100644 --- a/app/Filament/Resources/Products/Tables/ProductsTable.php +++ b/app/Filament/Resources/Products/Tables/ProductsTable.php @@ -29,9 +29,9 @@ class ProductsTable TextColumn::make('description') ->limit(50) ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('customers_count') - ->counts('customers') - ->label(__('app.owners')), + TextColumn::make('contracts_count') + ->counts('contracts') + ->label(__('app.contracts')), TextColumn::make('created_at') ->dateTime() ->sortable() diff --git a/app/Filament/Widgets/ResolvedTicketsWidget.php b/app/Filament/Widgets/ResolvedTicketsWidget.php index 0d3027c3..9b1f6537 100644 --- a/app/Filament/Widgets/ResolvedTicketsWidget.php +++ b/app/Filament/Widgets/ResolvedTicketsWidget.php @@ -45,7 +45,7 @@ class ResolvedTicketsWidget extends BaseWidget ->label(__('app.widget_customer')) ->searchable(), - TextColumn::make('customerProduct.product.name') + TextColumn::make('contract.product.name') ->label(__('app.product')) ->placeholder(__('app.general')), diff --git a/app/Models/Contract.php b/app/Models/Contract.php index 059eb768..75fe5846 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -7,9 +7,30 @@ 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'])] +#[Fillable(['product_id', 'customer_id', 'type', 'category', 'start_date', 'end_date', 'status', 'printed_at', 'signed_status', 'representative_name', 'representative_phone', 'representative_email'])] class Contract extends Model { + protected static function booted(): void + { + static::saving(function (self $contract) { + if ($contract->status === 'active') { + $exists = self::where('product_id', $contract->product_id) + ->where('category', $contract->category) + ->where('status', 'active') + ->when($contract->exists, fn ($q) => $q->where('id', '!=', $contract->id)) + ->exists(); + + if ($exists) { + throw new \RuntimeException( + __('feedback.contract_duplicate_body', [ + 'product' => $contract->product?->name ?? $contract->product_id, + 'customer' => $contract->customer?->name ?? '?', + ]) + ); + } + } + }); + } protected function casts(): array { return [ diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 023911b6..ada61a0e 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -6,14 +6,19 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; #[Fillable(['name', 'email', 'phone', 'address', 'status'])] class Customer extends Model { - public function products(): BelongsToMany + /** + * Các căn hộ khách hàng sở hữu (qua HĐ Ownership active) + */ + public function ownedProducts(): BelongsToMany { - return $this->belongsToMany(Product::class, 'customer_product') - ->withPivot('purchase_date') + return $this->belongsToMany(Product::class, 'contracts') + ->wherePivot('category', 'ownership') + ->wherePivot('status', 'active') ->withTimestamps(); } diff --git a/app/Models/CustomerProduct.php b/app/Models/CustomerProduct.php deleted file mode 100644 index a38d10d8..00000000 --- a/app/Models/CustomerProduct.php +++ /dev/null @@ -1,40 +0,0 @@ - 'date', - ]; - } - - public function customer(): BelongsTo - { - return $this->belongsTo(Customer::class); - } - - public function product(): BelongsTo - { - return $this->belongsTo(Product::class); - } - - public function feedbacks(): HasMany - { - return $this->hasMany(Feedback::class); - } -} diff --git a/app/Models/Feedback.php b/app/Models/Feedback.php index a83d4ed4..c168a0bc 100644 --- a/app/Models/Feedback.php +++ b/app/Models/Feedback.php @@ -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', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])] +#[Fillable(['customer_id', 'contract_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])] class Feedback extends Model { public function customer(): BelongsTo @@ -16,11 +16,6 @@ class Feedback extends Model return $this->belongsTo(Customer::class); } - public function customerProduct(): BelongsTo - { - return $this->belongsTo(CustomerProduct::class, 'customer_product_id'); - } - public function contract(): BelongsTo { return $this->belongsTo(Contract::class); diff --git a/app/Models/Product.php b/app/Models/Product.php index faf7bf8b..66daab9d 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -4,19 +4,11 @@ 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 { - public function customers(): BelongsToMany - { - return $this->belongsToMany(Customer::class, 'customer_product') - ->withPivot('purchase_date') - ->withTimestamps(); - } - public function contracts(): HasMany { return $this->hasMany(Contract::class); diff --git a/database/migrations/2026_05_12_075014_v2_restructure_contracts.php b/database/migrations/2026_05_12_075014_v2_restructure_contracts.php new file mode 100644 index 00000000..a4e25dd7 --- /dev/null +++ b/database/migrations/2026_05_12_075014_v2_restructure_contracts.php @@ -0,0 +1,69 @@ +string('category')->after('type')->default('ownership'); + $table->string('representative_name')->nullable()->after('signed_status'); + $table->string('representative_phone')->nullable()->after('representative_name'); + $table->string('representative_email')->nullable()->after('representative_phone'); + }); + + // 2. Drop customer_product_id FK and column from feedback + if (DB::getDriverName() === 'sqlite') { + // SQLite doesn't support dropping foreign keys cleanly, recreate table approach + Schema::table('feedback', function (Blueprint $table) { + // Drop the column directly (SQLite will handle) + }); + } + + try { + Schema::table('feedback', function (Blueprint $table) { + $table->dropForeign(['customer_product_id']); + }); + } catch (\Throwable) { + // FK may not exist or already dropped + } + + Schema::table('feedback', function (Blueprint $table) { + if (Schema::hasColumn('feedback', 'customer_product_id')) { + $table->dropColumn('customer_product_id'); + } + }); + + // 3. Drop customer_product pivot table + Schema::dropIfExists('customer_product'); + } + + public function down(): void + { + // Restore customer_product table + Schema::create('customer_product', function (Blueprint $table) { + $table->id(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->date('purchase_date')->nullable(); + $table->timestamps(); + $table->unique(['customer_id', 'product_id']); + }); + + // Restore customer_product_id on feedback + Schema::table('feedback', function (Blueprint $table) { + $table->foreignId('customer_product_id')->nullable()->after('customer_id'); + $table->foreign('customer_product_id')->references('id')->on('customer_product')->nullOnDelete(); + }); + + // Remove V2 columns from contracts + Schema::table('contracts', function (Blueprint $table) { + $table->dropColumn(['category', 'representative_name', 'representative_phone', 'representative_email']); + }); + } +}; diff --git a/database/seeders/AfterSalesSeeder.php b/database/seeders/AfterSalesSeeder.php index bda67894..9a4f49ff 100644 --- a/database/seeders/AfterSalesSeeder.php +++ b/database/seeders/AfterSalesSeeder.php @@ -2,13 +2,13 @@ namespace Database\Seeders; +use App\Enums\ContractCategory; use App\Enums\ContractStatus; use App\Enums\ContractType; use App\Enums\HandoverStatus; use App\Enums\ServiceType; use App\Models\Contract; use App\Models\Customer; -use App\Models\CustomerProduct; use App\Models\Department; use App\Models\Feedback; use App\Models\FeedbackAttachment; @@ -119,65 +119,62 @@ class AfterSalesSeeder extends Seeder $c6 = Customer::create(['name' => 'Đỗ Văn Phúc', 'email' => 'dovanphuc@gmail.com', 'phone' => '0954678901', 'address' => 'Thủ Đức, TP.HCM', 'status' => 'inactive']); // ============================================================ - // 7. CUSTOMER-PRODUCT pivots - // ============================================================ - $c1->products()->attach($p1->id, ['purchase_date' => '2024-01-15']); - $c1->products()->attach($p2->id, ['purchase_date' => '2024-06-01']); - $c2->products()->attach($p1->id, ['purchase_date' => '2024-03-20']); - $c3->products()->attach($p3->id, ['purchase_date' => '2024-09-10']); - $c4->products()->attach($p2->id, ['purchase_date' => '2025-01-05']); - $c4->products()->attach($p5->id, ['purchase_date' => '2025-02-15']); - $c5->products()->attach($p5->id, ['purchase_date' => '2024-11-01']); - $c6->products()->attach($p6->id, ['purchase_date' => '2023-06-15']); - - // ============================================================ - // 8. CONTRACTS (6 contracts) + // 7. CONTRACTS (V2: category + representative) // ============================================================ $contract1 = Contract::create([ 'product_id' => $p1->id, 'customer_id' => $c1->id, - 'type' => ContractType::SALE->value, + 'category' => ContractCategory::OWNERSHIP->value, + 'type' => ContractType::PURCHASE->value, 'start_date' => '2024-01-15', 'end_date' => null, 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đã ký', + 'representative_name' => 'Nguyễn Thị Hoa', + 'representative_phone' => '0901000111', + 'representative_email' => 'hoant@example.com', ]); $contract2 = Contract::create([ 'product_id' => $p2->id, 'customer_id' => $c1->id, - 'type' => ContractType::LEASE->value, + 'category' => ContractCategory::BUSINESS->value, + 'type' => ContractType::RENTAL->value, 'start_date' => '2024-06-01', 'end_date' => '2026-06-01', 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đã ký', ]); $contract3 = Contract::create([ - 'product_id' => $p1->id, 'customer_id' => $c2->id, - 'type' => ContractType::SALE->value, - 'start_date' => '2024-03-20', 'end_date' => null, - 'status' => ContractStatus::ACTIVE->value, + 'product_id' => $p2->id, 'customer_id' => $c4->id, + 'category' => ContractCategory::BUSINESS->value, + 'type' => ContractType::RENTAL->value, + 'start_date' => '2025-01-05', 'end_date' => '2026-01-05', + 'status' => ContractStatus::EXPIRED->value, 'signed_status' => 'Đã ký', ]); $contract4 = Contract::create([ 'product_id' => $p3->id, 'customer_id' => $c3->id, + 'category' => ContractCategory::BUSINESS->value, 'type' => ContractType::BCC->value, 'start_date' => '2024-09-10', 'end_date' => '2029-09-10', 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đang chờ ký', ]); $contract5 = Contract::create([ - 'product_id' => $p2->id, 'customer_id' => $c4->id, - 'type' => ContractType::LEASE->value, - 'start_date' => '2025-01-05', 'end_date' => '2026-01-05', - 'status' => ContractStatus::EXPIRED->value, - 'signed_status' => 'Đã ký', - ]); - $contract6 = Contract::create([ 'product_id' => $p5->id, 'customer_id' => $c5->id, - 'type' => ContractType::SALE->value, + 'category' => ContractCategory::OWNERSHIP->value, + 'type' => ContractType::PURCHASE->value, 'start_date' => '2024-11-01', 'end_date' => null, 'status' => ContractStatus::ACTIVE->value, 'signed_status' => 'Đã ký', ]); + $contract6 = Contract::create([ + 'product_id' => $p1->id, 'customer_id' => $c2->id, + 'category' => ContractCategory::OWNERSHIP->value, + 'type' => ContractType::PURCHASE->value, + 'start_date' => '2024-03-20', 'end_date' => '2024-12-31', + 'status' => ContractStatus::LIQUIDATED->value, + 'signed_status' => 'Đã ký', + ]); // ============================================================ - // 9. HANDOVERS (6 handovers) + // 8. HANDOVERS (6 handovers) // ============================================================ Handover::create(['product_id' => $p1->id, 'customer_id' => $c1->id, 'handover_date' => '2024-02-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao đầy đủ chìa khóa, thẻ từ và sổ bảo hành.']); Handover::create(['product_id' => $p1->id, 'customer_id' => $c2->id, 'handover_date' => '2024-04-20', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Đã bàn giao, khách yêu cầu kiểm tra lại hệ thống nước nóng.']); @@ -187,7 +184,7 @@ class AfterSalesSeeder extends Seeder Handover::create(['product_id' => $p5->id, 'customer_id' => $c5->id, 'handover_date' => '2025-01-15', 'status' => HandoverStatus::HANDED_OVER->value, 'remark' => 'Bàn giao thành công, khách hài lòng với chất lượng.']); // ============================================================ - // 10. PRODUCT SERVICES (6 services) + // 9. PRODUCT SERVICES (6 services) // ============================================================ ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2024-03-01', 'remark' => 'Phí quản lý hàng tháng 5.000.000 VND/căn.']); ProductService::create(['product_id' => $p1->id, 'service_type' => ServiceType::GRATITUDE->value, 'status' => 'completed', 'actual_collection_date' => '2024-06-15', 'remark' => 'Quà tri ân khách hàng nhân dịp khai trương tiện ích mới.']); @@ -197,7 +194,7 @@ class AfterSalesSeeder extends Seeder ProductService::create(['product_id' => $p5->id, 'service_type' => ServiceType::MANAGEMENT_FEE->value, 'status' => 'active', 'actual_collection_date' => '2025-02-01', 'remark' => 'Phí quản lý hàng tháng 3.500.000 VND/căn.']); // ============================================================ - // 11. FEEDBACK TAGS (6 tags) + // 10. FEEDBACK TAGS (6 tags) // ============================================================ $tagLeaking = FeedbackTag::create(['name' => 'Rò rỉ', 'slug' => 'leaking', 'color' => '#3b82f6']); $tagElectrical = FeedbackTag::create(['name' => 'Điện nước', 'slug' => 'electrical', 'color' => '#f59e0b']); @@ -207,13 +204,12 @@ class AfterSalesSeeder extends Seeder $tagSuggestion = FeedbackTag::create(['name' => 'Đề xuất', 'slug' => 'suggestion', 'color' => '#06b6d4']); // ============================================================ - // 12. FEEDBACKS (8 feedbacks - various statuses) + // 11. FEEDBACKS (8 feedbacks - V2: no customer_product_id, use contract_id) // ============================================================ // Feedback 1: Pending - Trần rò rỉ - $cp1 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p1->id)->first(); $fb1 = Feedback::create([ - 'customer_id' => $c1->id, 'customer_product_id' => $cp1->id, 'contract_id' => $contract1->id, + 'customer_id' => $c1->id, 'contract_id' => $contract1->id, 'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffA->id, 'current_department_id' => $deptCskh->id, 'title' => 'Trần nhà bị rò rỉ nước mưa', @@ -222,9 +218,8 @@ class AfterSalesSeeder extends Seeder ]); // Feedback 2: Processing - Nâng cấp chỗ đậu xe - $cp2 = CustomerProduct::where('customer_id', $c1->id)->where('product_id', $p2->id)->first(); $fb2 = Feedback::create([ - 'customer_id' => $c1->id, 'customer_product_id' => $cp2->id, 'contract_id' => $contract2->id, + 'customer_id' => $c1->id, 'contract_id' => $contract2->id, 'feedback_channel_id' => $chPhone->id, 'assigned_to' => $managerCskh->id, 'current_department_id' => $deptCskh->id, 'title' => 'Yêu cầu nâng cấp chỗ đậu xe', @@ -233,9 +228,8 @@ class AfterSalesSeeder extends Seeder ]); // Feedback 3: Resolved - Điều hòa hỏng - $cp3 = CustomerProduct::where('customer_id', $c2->id)->where('product_id', $p1->id)->first(); $fb3 = Feedback::create([ - 'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id, + 'customer_id' => $c2->id, 'contract_id' => $contract6->id, 'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffA->id, 'current_department_id' => $deptKt->id, 'is_escalated' => false, 'title' => 'Điều hòa phòng ngủ chính không mát', @@ -243,9 +237,9 @@ class AfterSalesSeeder extends Seeder 'status' => 'resolved', ]); - // Feedback 4: Pending - Góp ý cải thiện dịch vụ + // Feedback 4: Pending - Góp ý cải thiện dịch vụ (general) $fb4 = Feedback::create([ - 'customer_id' => $c3->id, 'customer_product_id' => null, 'contract_id' => null, + 'customer_id' => $c3->id, 'contract_id' => null, 'feedback_channel_id' => $chApp->id, 'assigned_to' => null, 'current_department_id' => $deptCskh->id, 'title' => 'Đề xuất cải thiện dịch vụ vệ sinh', @@ -253,10 +247,9 @@ class AfterSalesSeeder extends Seeder 'status' => 'pending', ]); - // Feedback 5: Closed - Bản lề tủ bếp - $cp5 = CustomerProduct::where('customer_id', $c4->id)->where('product_id', $p2->id)->first(); + // Feedback 5: Closed - Bản lề tủ bếp (vẫn ref contract đã expired) $fb5 = Feedback::create([ - 'customer_id' => $c4->id, 'customer_product_id' => $cp5->id, 'contract_id' => $contract5->id, + 'customer_id' => $c4->id, 'contract_id' => $contract3->id, 'feedback_channel_id' => $chEmail->id, 'assigned_to' => $staffB->id, 'current_department_id' => $deptKt->id, 'title' => 'Bản lề tủ bếp bị gãy', @@ -265,19 +258,18 @@ class AfterSalesSeeder extends Seeder ]); // Feedback 6: Processing - Ổ cắm điện hư - $cp6 = CustomerProduct::where('customer_id', $c5->id)->where('product_id', $p5->id)->first(); $fb6 = Feedback::create([ - 'customer_id' => $c5->id, 'customer_product_id' => $cp6->id, 'contract_id' => $contract6->id, + 'customer_id' => $c5->id, 'contract_id' => $contract5->id, 'feedback_channel_id' => $chPhone->id, 'assigned_to' => $staffA->id, 'current_department_id' => $deptKt->id, 'is_escalated' => true, - 'title' => 'Ổ c插座 điện phòng bếp bị hư, có mùi khét', + 'title' => 'Ổ cắm điện phòng bếp bị hư, có mùi khét', 'content' => 'Ổ cắm điện cạnh bồn rửa chén bị hư, phát ra mùi khét khi cắm thiết bị. Đây là vấn đề an toàn, cần xử lý gấp.', 'status' => 'processing', ]); // Feedback 7: Resolved - Hợp đồng thuê $fb7 = Feedback::create([ - 'customer_id' => $c4->id, 'customer_product_id' => null, 'contract_id' => $contract5->id, + 'customer_id' => $c4->id, 'contract_id' => $contract3->id, 'feedback_channel_id' => $chWalkin->id, 'assigned_to' => $staffB->id, 'current_department_id' => $deptPhaply->id, 'title' => 'Hỏi về gia hạn hợp đồng thuê', @@ -285,9 +277,9 @@ class AfterSalesSeeder extends Seeder 'status' => 'resolved', ]); - // Feedback 8: Pending - Phàn nàn về tiếng ồn + // Feedback 8: Pending - Phàn nàn tiếng ồn $fb8 = Feedback::create([ - 'customer_id' => $c2->id, 'customer_product_id' => $cp3->id, 'contract_id' => $contract3->id, + 'customer_id' => $c2->id, 'contract_id' => $contract6->id, 'feedback_channel_id' => $chZalo->id, 'assigned_to' => $staffB->id, 'current_department_id' => $deptCskh->id, 'title' => 'Phàn nàn về tiếng ồn từ tầng trên', @@ -296,7 +288,7 @@ class AfterSalesSeeder extends Seeder ]); // ============================================================ - // 13. TAG ATTACHMENTS + // 12. TAG ATTACHMENTS // ============================================================ $fb1->tags()->attach([$tagLeaking->id, $tagMaintenance->id]); $fb2->tags()->attach([$tagSuggestion->id]); @@ -308,10 +300,8 @@ class AfterSalesSeeder extends Seeder $fb8->tags()->attach([$tagComplaint->id]); // ============================================================ - // 14. FEEDBACK INTERACTIONS (12 interactions) + // 13. FEEDBACK INTERACTIONS // ============================================================ - - // FB1: Tạo mới + ghi chú FeedbackInteraction::create([ 'feedback_id' => $fb1->id, 'user_id' => $admin->id, 'type' => 'note', 'content' => 'Tiếp nhận phản ánh qua Email. Giao cho nhân viên Phạm Thị xử lý.', @@ -321,28 +311,20 @@ class AfterSalesSeeder extends Seeder 'feedback_id' => $fb1->id, 'user_id' => $staffA->id, 'type' => 'note', 'content' => 'Đã liên hệ khách hàng qua điện thoại. Lên lịch kiểm tra vào thứ Ba tuần sau.', ]); - - // FB2: Phân công FeedbackInteraction::create([ 'feedback_id' => $fb2->id, 'user_id' => $managerCskh->id, 'type' => 'note', 'content' => 'Đã gửi báo giá nâng cấp chỗ đậu xe qua email. Chờ khách xác nhận.', ]); - - // FB3: Xử lý xong FeedbackInteraction::create([ 'feedback_id' => $fb3->id, 'user_id' => $staffA->id, 'type' => 'status_change', 'content' => 'Kỹ thuật viên đã kiểm tra và thay thế block điều hòa ngày 10/06. Khách hàng xác nhận hoạt động bình thường.', 'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']], ]); - - // FB5: Đóng phiếu FeedbackInteraction::create([ 'feedback_id' => $fb5->id, 'user_id' => $staffB->id, 'type' => 'status_change', 'content' => 'Đã thay thế bản lề mới ngày 01/03. Tất cả tủ bếp hoạt động bình thường.', 'changes' => ['status' => ['old' => 'processing', 'new' => 'closed']], ]); - - // FB6: Đang xử lý + ghi chú khẩn cấp FeedbackInteraction::create([ 'feedback_id' => $fb6->id, 'user_id' => $staffA->id, 'type' => 'note', 'content' => 'Đã cử kỹ thuật viên khẩn cấp đến kiểm tra. Yêu cầu khách không sử dụng ổ cắm cho đến khi sửa xong.', @@ -352,8 +334,6 @@ class AfterSalesSeeder extends Seeder 'content' => 'Kỹ thuật viên đã kiểm tra, phát hiện dây điện bị hở. Đang chờ vật tư thay thế.', 'changes' => ['status' => ['old' => 'pending', 'new' => 'processing']], ]); - - // FB7: Phân công + chuyển phòng ban FeedbackInteraction::create([ 'feedback_id' => $fb7->id, 'user_id' => $managerCskh->id, 'type' => 'assignment', 'content' => 'Chuyển yêu cầu gia hạn hợp đồng sang Phòng Pháp lý.', @@ -364,15 +344,13 @@ class AfterSalesSeeder extends Seeder 'content' => 'Đã soạn thảo phụ lục hợp đồng gia hạn. Đang chờ khách hàng ký.', 'changes' => ['status' => ['old' => 'processing', 'new' => 'resolved']], ]); - - // FB8: Ghi chú FeedbackInteraction::create([ 'feedback_id' => $fb8->id, 'user_id' => $staffB->id, 'type' => 'note', 'content' => 'Đã liên hệ ban quản lý tòa nhà để xác minh thông tin. BQL cho biết sẽ nhắc nhở cư dân tầng trên.', ]); // ============================================================ - // 15. TICKET TRANSFER LOGS (2 transfers) + // 14. TICKET TRANSFER LOGS // ============================================================ TicketTransferLog::create([ 'feedback_id' => $fb3->id, @@ -390,17 +368,15 @@ class AfterSalesSeeder extends Seeder ]); // ============================================================ - // 16. FEEDBACK ATTACHMENTS (6 attachments - public disk) + // 15. FEEDBACK ATTACHMENTS // ============================================================ $disk = Storage::disk('public'); - - // Tạo file mẫu trên public disk $disk->put('feedback-attachments/bao-cao-kiem-tra-dieu-hoa.pdf', '%PDF-1.4 Mẫu báo cáo kiểm tra điều hòa - Demo'); - $disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000)); // dummy image - $disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200)); // dummy image + $disk->put('feedback-attachments/anh-tran-ro-ri.jpg', str_repeat('x', 1000)); + $disk->put('feedback-attachments/anh-ban-le-hong.png', str_repeat('y', 1200)); $disk->put('feedback-attachments/bao-gia-nang-cap.pdf', '%PDF-1.4 Báo giá nâng cấp chỗ đậu xe - Demo'); $disk->put('feedback-attachments/phu-luc-hop-dong.pdf', '%PDF-1.4 Phụ lục hợp đồng gia hạn - Demo'); - $disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800)); // dummy image + $disk->put('feedback-attachments/anh-o-cam-hu.jpg', str_repeat('z', 800)); FeedbackAttachment::create([ 'uuid' => (string) Str::uuid(), diff --git a/lang/en/app.php b/lang/en/app.php index cbb41bbf..8797fd85 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -88,7 +88,11 @@ return [ 'end_date' => 'End Date', 'printed_at' => 'Print Date', 'signed_status' => 'Signed Status', + 'contract_category_section' => 'Contract Classification', 'contract_info' => 'Contract Information', + 'representative_info' => 'Representative Information', + 'representative_desc' => 'Person representing the owner (if any)', + 'representative' => 'Representative', 'feedback_history' => 'Feedback History', 'service_type' => 'Service Type', 'tab_all' => 'All', diff --git a/lang/en/enums.php b/lang/en/enums.php index 4d1f6078..65cd58a3 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -32,11 +32,21 @@ return [ 'liquidated' => 'Liquidated', ], - // Contract Type + // Contract Category + 'contract_category' => [ + 'ownership' => 'Ownership', + 'business' => 'Business', + ], + 'contract_category_label' => 'Contract Category', + + // Contract Type (V2) 'contract_type' => [ - 'sale' => 'Sale contract', - 'lease' => 'Lease contract', - 'bcc' => 'Business cooperation contract (BCC)', + 'deposit' => 'Deposit', + 'purchase' => 'Purchase', + 'transfer' => 'Transfer', + 'rental' => 'Rental', + 'self_business' => 'Self Business', + 'bcc' => 'Business Cooperation (BCC)', ], // Labels for Select filters diff --git a/lang/en/feedback.php b/lang/en/feedback.php index ba1df163..a092d460 100644 --- a/lang/en/feedback.php +++ b/lang/en/feedback.php @@ -60,4 +60,8 @@ return [ 'log_export_done' => 'Exported :tables tables, :rows rows', 'log_transfer' => ':title transferred from :from to :to', 'log_close' => 'Ticket closed: :title', + + // Contract validation + 'contract_duplicate_title' => 'Product already has active :category contract', + 'contract_duplicate_body' => 'Product :product already has an active contract of this category with :customer. Please liquidate the existing contract before creating a new one.', ]; diff --git a/lang/vi/app.php b/lang/vi/app.php index ecd09895..29517219 100644 --- a/lang/vi/app.php +++ b/lang/vi/app.php @@ -88,7 +88,11 @@ return [ 'end_date' => 'Ngày kết thúc', 'printed_at' => 'Ngày in HĐ', 'signed_status' => 'Tình trạng ký', + 'contract_category_section' => 'Phân loại hợp đồng', 'contract_info' => 'Thông tin hợp đồng', + 'representative_info' => 'Thông tin người đại diện', + 'representative_desc' => 'Người đại diện thay mặt chủ sở hữu (nếu có)', + 'representative' => 'Người đại diện', 'feedback_history' => 'Lịch sử phản ánh', 'service_type' => 'Loại dịch vụ', 'tab_all' => 'Tất cả', diff --git a/lang/vi/enums.php b/lang/vi/enums.php index 4ee6c840..2fc66fe4 100644 --- a/lang/vi/enums.php +++ b/lang/vi/enums.php @@ -32,11 +32,21 @@ return [ 'liquidated' => 'Đã thanh lý', ], - // Contract Type + // Contract Category + 'contract_category' => [ + 'ownership' => 'Sở hữu', + 'business' => 'Kinh doanh', + ], + 'contract_category_label' => 'Phân loại HĐ', + + // Contract Type (V2) 'contract_type' => [ - 'sale' => 'HĐ Mua bán', - 'lease' => 'HĐ Thuê', - 'bcc' => 'HĐ Hợp tác kinh doanh (BCC)', + 'deposit' => 'Đặt cọc', + 'purchase' => 'Mua bán', + 'transfer' => 'Chuyển nhượng', + 'rental' => 'Cho thuê', + 'self_business' => 'Tự doanh', + 'bcc' => 'Hợp tác KD (BCC)', ], // Labels cho Select filters diff --git a/lang/vi/feedback.php b/lang/vi/feedback.php index 8ad13a38..4bbb0a53 100644 --- a/lang/vi/feedback.php +++ b/lang/vi/feedback.php @@ -60,4 +60,8 @@ return [ 'log_export_done' => 'Xuất :tables bảng, :rows dòng', 'log_transfer' => ':title: chuyển từ :from sang :to', 'log_close' => 'Đóng ticket: :title', + + // Contract validation + 'contract_duplicate_title' => 'Căn hộ đã có HĐ :category active', + 'contract_duplicate_body' => 'Căn hộ :product đã có HĐ thuộc nhóm này với khách :customer. Vui lòng thanh lý HĐ cũ trước khi tạo mới.', ];