Kimi chinh sua
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/app/imports
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
|
||||
314
AGENTS.md
Normal file
314
AGENTS.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# HQLAND - TRẠNG THÁI CODEBASE & LỘ TRÌNH PHÁT TRIỂN
|
||||
|
||||
> File này được tạo để lưu trữ ngữ cảnh dự án cho các phiên làm việc sau.
|
||||
> **Cập nhật:** 24/04/2026
|
||||
> **Dự án:** HQLand - Hệ thống quản lý Bất động sản
|
||||
> **Stack:** Laravel 13 + Filament v5.5 (Schemas Architecture) + PostgreSQL + UUID
|
||||
|
||||
---
|
||||
|
||||
## 1. THÔNG TIN KẾT NỐI DATABASE (CRITICAL)
|
||||
|
||||
### Database Chính (Production Data)
|
||||
- **Connection:** pgsql
|
||||
- **Host:** pgsql (trong Docker), 127.0.0.1 (từ Host machine)
|
||||
- **Port:** 5432
|
||||
- **Database:** laravel
|
||||
- **Username:** sail
|
||||
- **Password:** password
|
||||
- **Dữ liệu hiện có:** 120 khách hàng, 45 sản phẩm, 139 hợp đồng
|
||||
|
||||
### Database Thử Nghiệm
|
||||
- **Database:** laravel_testing (đã tạo)
|
||||
- **Cách chạy test:** `DB_HOST=127.0.0.1 ./vendor/bin/pest`
|
||||
- **Cách chạy artisan:** `DB_HOST=127.0.0.1 php artisan tinker`
|
||||
|
||||
### Quy tắc VÀNG
|
||||
- **TUYỆT ĐỐI KHÔNG** dùng `migrate:fresh` trên database chính.
|
||||
- Tài khoản admin: `admin@phuongtc.com` / `1Qazxsw2@!321`
|
||||
- Dữ liệu Excel đã import là tài sản quý - không xóa.
|
||||
|
||||
---
|
||||
|
||||
## 2. KIẾN TRÚC KỸ THUẬT
|
||||
|
||||
### Tech Stack
|
||||
| Thành phần | Phiên bản / Công nghệ |
|
||||
|------------|----------------------|
|
||||
| Framework | Laravel 13.x |
|
||||
| PHP | 8.3 |
|
||||
| Admin Panel | Filament v5.5 |
|
||||
| Kiến trúc UI | **Schemas Architecture** (Tách Form/Table ra khỏi Resource) |
|
||||
| Database | PostgreSQL |
|
||||
| Khóa chính | UUID (100% các bảng) |
|
||||
| Excel | PhpSpreadsheet 5.7 |
|
||||
| Testing | Pest PHP 4.6 |
|
||||
|
||||
### Quy chuẩn Code
|
||||
1. **LUÔN** dùng `Schemas` class. **KHÔNG** định nghĩa inline trong Resource.
|
||||
2. `Grid` và `Section` nằm trong `Filament\Schemas\Components`.
|
||||
3. Khi render HTML động trong Form, dùng **Inline Styles** thay vì Tailwind class.
|
||||
4. Mọi trường JSONB trong Model phải khai báo trong `$casts = ['field' => 'array']`.
|
||||
5. Naming database: **snake_case** cho mọi bảng và cột.
|
||||
|
||||
---
|
||||
|
||||
## 3. CẤU TRÚC MODULE HIỆN TẠI
|
||||
|
||||
### 3.1. Warehouse (Kho hàng)
|
||||
**Models:** `Project`, `Product`
|
||||
|
||||
**Project:**
|
||||
- `code`, `name`, `type`, `address`
|
||||
- `payment_template_id` (relationship với PaymentTemplate)
|
||||
|
||||
**Product:**
|
||||
- `code`, `project_id`, `product_type` (LAND | APARTMENT)
|
||||
- `area`, `price_per_unit`, `total_price`
|
||||
- `qsdd_value`, `foundation_temp_value`, `contract_temp_value`
|
||||
- `infrastructure_status` (JSONB)
|
||||
- `custom_data` (JSONB): block, building_density, legal_status_raw
|
||||
- `status`
|
||||
|
||||
**Filament Resources:**
|
||||
- `ProjectResource` → `ProjectForm` (Schemas)
|
||||
- `ProductResource`
|
||||
|
||||
---
|
||||
|
||||
### 3.2. CRM (Khách hàng)
|
||||
**Model:** `Customer`
|
||||
|
||||
**Cấu trúc:**
|
||||
- `type`: INDIVIDUAL | COMPANY
|
||||
- `full_name`, `cmnd_cccd`, `tax_code`, `title`
|
||||
- `phone`, `secondary_phones` (JSONB)
|
||||
- `email`, `dob`
|
||||
- `permanent_address`, `contact_address` (lưu cứng, không JSON)
|
||||
- `id_issue_date`, `id_issue_place`
|
||||
- `representative_id` (self-referencing, cho công ty)
|
||||
|
||||
**Quan hệ:**
|
||||
- `representedCompanies()`: Công ty mà khách hàng đại diện
|
||||
- `representative()`: Ngườ đại diện của công ty
|
||||
- `contracts()`: belongsToMany qua `contract_customers`
|
||||
|
||||
**Filament Resources:**
|
||||
- `CustomerResource` → `CustomerForm` + `CustomersTable` (Schemas)
|
||||
- Form hỗ trợ chuyển đổi INDIVIDUAL/COMPANY động (live)
|
||||
- Copy địa chỉ thường trú → liên hệ (suffixAction)
|
||||
|
||||
---
|
||||
|
||||
### 3.3. Contracts (Hợp đồng & Tài chính)
|
||||
**Model:** `Contract`
|
||||
|
||||
**Cấu trúc:**
|
||||
- `contract_number`, `contract_type` (HĐMB | HĐGV | HĐDC)
|
||||
- `product_id`, `status`
|
||||
- `signing_date`, `sale_date`, `hql_confirmation_date`
|
||||
- `land_value`, `foundation_value`, `total_value`, `total_value_with_foundation`
|
||||
- `paid_amount`, `remaining_amount`, `excess_amount`
|
||||
- `discount_details` (JSONB)
|
||||
- `brokerage_name`, `stored_contract_count`, `filing_note`
|
||||
- `transfer_order`: 0 = chủ hiện tại, 1 = F0, 2+ = F1, F2...
|
||||
|
||||
**Logic tự động trong Model (booted):**
|
||||
- `total_value` = `land_value` + `foundation_value` (nếu có giá trị)
|
||||
- Fallback: lấy từ `product.total_price` khi tạo mới
|
||||
- `remaining_amount` = `total_value` - `paid_amount`
|
||||
|
||||
**Quan hệ:**
|
||||
- `product()`, `customers()` (belongsToMany qua contract_customers)
|
||||
- `appendices()`, `paymentSchedule()`, `scheduleItems()` (HasManyThrough)
|
||||
- `payments()`, `paymentFines()`
|
||||
|
||||
**Filament Resources:**
|
||||
- `ContractResource` → `ContractForm` + `ContractsTable`
|
||||
- Action "Tạo lịch TT" trong Table (gọi `ContractScheduleService`)
|
||||
- Form có tính toán live: land_value + foundation_value = total_value
|
||||
- Hiển thị discount_details dạng grid inline style
|
||||
|
||||
---
|
||||
|
||||
### 3.4. Finance (Tài chính & Thu tiền)
|
||||
**Models:** `PaymentTemplate`, `PaymentSchedule`, `PaymentScheduleItem`, `Payment`, `PaymentFine`
|
||||
|
||||
**PaymentTemplate:**
|
||||
- `project_id`, `name`, `is_default`
|
||||
- `items()`: các đợt thanh toán mẫu
|
||||
|
||||
**PaymentSchedule:**
|
||||
- `contract_id`, `template_id`
|
||||
- `items()`: các đợt thanh toán thực tế
|
||||
|
||||
**PaymentScheduleItem:**
|
||||
- `schedule_id` (hoặc `template_id` - dùng chung bảng)
|
||||
- `installment_no`, `type` (PaymentType enum), `percentage`, `amount`, `due_date`
|
||||
- `days_after_signing`, `days_after_previous`
|
||||
|
||||
**Payment:**
|
||||
- `contract_id`, `schedule_item_id`, `amount`, `paid_date`
|
||||
- `method`, `receipt_number`, `metadata` (JSONB)
|
||||
|
||||
**PaymentObserver (TỰ ĐỘNG):**
|
||||
- Khi tạo/sửa/xóa Payment:
|
||||
1. Tính lại `contract.paid_amount` = SUM(payments)
|
||||
2. Tính lại `remaining_amount` và `excess_amount`
|
||||
3. Nếu có `excess_amount` > 0: **Tự động khấu trừ** vào đợt thanh toán tiếp theo chưa đủ tiền (tạo Payment auto)
|
||||
|
||||
**Filament Resources:**
|
||||
- `PaymentResource` → `PaymentForm` + `PaymentsTable`
|
||||
- Form chọn Contract → chọn Đợt thanh toán (cascade)
|
||||
- Table có filter theo phương thức và ngày thu
|
||||
|
||||
---
|
||||
|
||||
## 4. CÁC COMMAND IMPORT DỮ LIỆU
|
||||
|
||||
### `import:products-excel {file=sanpham.xlsx}`
|
||||
- Import sản phẩm vào dự án "Hà Quang 1"
|
||||
- Tự động parse hạ tầng từ chuỗi "Key: Value - Key2: Value2"
|
||||
- Tạo custom_data (block, building_density...)
|
||||
|
||||
### `import:customers-excel {file=khachhang.xlsx}`
|
||||
- Import khách hàng cá nhân
|
||||
- Tách nhiều số điện thoại (dấu phẩy, gạch chéo, xuống dòng)
|
||||
- Parse ngày tháng Excel (số serial hoặc chuỗi)
|
||||
- Tự động tạo mẫu Công ty + Ngườ đại diện (Công ty TNHH BĐS Thịnh Vượng)
|
||||
|
||||
### `import:contracts-complex {hopdong=hopdong.xlsx} {hdkh=Hd_kh.xlsx}`
|
||||
- Logic "Bắc cầu" 2 file:
|
||||
1. `hopdong.xlsx`: Dữ liệu tài chính (theo Số HĐMB)
|
||||
2. `Hd_kh.xlsx`: Liên kết Khách hàng - Lô đất - Thứ tự chuyển nhượng
|
||||
- Tìm mapping giữa mã lô và số HĐMB (str_contains)
|
||||
- Tạo/cập nhật Contract + liên kết pivot `contract_customers`
|
||||
|
||||
### Các file Excel quan trọng (KHÔNG ĐƯỢC XÓA)
|
||||
- `hopdong.xlsx` - Dữ liệu hợp đồng tài chính
|
||||
- `Hd_kh.xlsx` - Liên kết hợp đồng-khách hàng
|
||||
- `khachhang.xlsx` - Danh sách khách hàng
|
||||
- `sanpham.xlsx` - Danh sách sản phẩm/lô đất
|
||||
|
||||
---
|
||||
|
||||
## 5. TÌNH TRẠNG CÁC PHẦN ĐÃ LÀM / ĐANG DỞ
|
||||
|
||||
### 5.1. Đã hoàn thành
|
||||
- [x] Kiến trúc Schemas cho tất cả Resources
|
||||
- [x] Import Customers, Products, Contracts từ Excel
|
||||
- [x] Mở rộng bảng customers (type, representative, addresses...)
|
||||
- [x] Mở rộng bảng contracts (land_value, foundation_value, discount_details...)
|
||||
- [x] ContractScheduleService - Tạo lịch thanh toán từ template
|
||||
- [x] PaymentObserver - Tự động tính toán tài chính + khấu trừ dư
|
||||
- [x] PaymentResource (Form + Table)
|
||||
- [x] Test: ContractFinanceFlowTest (PASS)
|
||||
- [x] Cấu hình PHPUnit dùng PostgreSQL testing database
|
||||
|
||||
### 5.2. Đang dở / Cần tiếp tục
|
||||
- [ ] **ContractForm:** `payment_template_id` đang `dehydrated(false)` - chưa tự động tạo lịch khi tạo hợp đồng mới từ form (hiện chỉ có trong CreateContract page sau khi submit)
|
||||
- [ ] **PaymentsTable:** Chưa có cột trạng thái đối soát (so sánh với schedule_item amount)
|
||||
- [ ] **Module Chiết khấu (Discounts):** Chưa có engine tính toán tự động dựa trên `discount_details`
|
||||
- [ ] **PaymentFine:** Model đã có nhưng chưa có Resource/Form
|
||||
- [ ] **Appendix & Settlement:** Chưa có Filament Resources
|
||||
- [ ] **Báo cáo:** Chưa có Dashboard thống kê
|
||||
- [ ] **Tự động hóa lịch trình cho 139 HĐ:** Cần command hoặc action để generate schedule hàng loạt
|
||||
|
||||
### 5.3. Vấn đề kỹ thuật cần xử lý
|
||||
- [ ] `payment_template_id` trong ContractForm cần hook `afterCreate` hoặc đổi thành dehydrated + xử lý trong CreateContract
|
||||
- [ ] PaymentsTable nên hiển thị `scheduleItem.type` và trạng thái đối soát
|
||||
- [ ] ContractTable có thể thêm cột `paid_amount` / `remaining_amount` (đã có trong Resource nhưng chưa commit staged)
|
||||
- [ ] Cần kiểm tra logic `updateOrCreate` trong ImportContractsComplex với nhiều khách hàng cùng 1 hợp đồng
|
||||
|
||||
---
|
||||
|
||||
## 6. LỘ TRÌNH PHÁT TRIỂN TIẾP THEO (ĐỀ XUẤT)
|
||||
|
||||
### Giai đoạn 1: Hoàn thiện Core Finance (Ưu tiên CAO)
|
||||
1. **Fix ContractForm:** Cho phép chọn template và tự động tạo lịch thanh toán ngay khi tạo hợp đồng
|
||||
2. **Hoàn thiện PaymentForm:** Thêm validation số tiền không vượt quá công nợ đợt
|
||||
3. **Cập nhật PaymentsTable:** Thêm cột "Đợt TT", "Trạng thái đối soát", "Còn thiếu"
|
||||
4. **Command generate schedule hàng loạt:** `php artisan contracts:generate-schedules` cho 139 hợp đồng đã import
|
||||
|
||||
### Giai đoạn 2: Module Bổ sung (Ưu tiên TRUNG BÌNH)
|
||||
5. **PaymentFine Resource:** Quản lý tiền phạt chậm thanh toán
|
||||
6. **Appendix Resource:** Quản lý phụ lục hợp đồng
|
||||
7. **Settlement Resource:** Quản lý thanh lý hợp đồng
|
||||
8. **Discount Engine:** Tính toán tự động chiết khấu từ `discount_details` vào giá trị hợp đồng
|
||||
|
||||
### Giai đoạn 3: Báo cáo & Tối ưu (Ưu tiên THẤP)
|
||||
9. **Dashboard Tài chính:** Tổng doanh thu, dòng tiền dự kiến, công nợ phải thu
|
||||
10. **Báo cáo theo Dự án:** Thống kê bán hàng, thanh toán theo từng dự án
|
||||
11. **Export Excel:** Xuất báo cáo công nợ khách hàng
|
||||
12. **Notification:** Cảnh báo đợt thanh toán sắp đến hạn
|
||||
|
||||
---
|
||||
|
||||
## 7. CÂU LỆNH THƯỜNG DÙNG
|
||||
|
||||
```bash
|
||||
# Chạy test
|
||||
DB_HOST=127.0.0.1 ./vendor/bin/pest
|
||||
|
||||
# Chạy test cụ thể
|
||||
DB_HOST=127.0.0.1 ./vendor/bin/pest --filter="ContractFinanceFlowTest"
|
||||
|
||||
# Import dữ liệu
|
||||
db:host=127.0.0.1 php artisan import:products-excel
|
||||
db:host=127.0.0.1 php artisan import:customers-excel
|
||||
db:host=127.0.0.1 php artisan import:contracts-complex
|
||||
|
||||
# Tinker
|
||||
DB_HOST=127.0.0.1 php artisan tinker
|
||||
|
||||
# Migrate (KHÔNG dùng fresh!)
|
||||
DB_HOST=127.0.0.1 php artisan migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. DANH SÁCH FILE ĐÃ THAY ĐỔI (Git Status)
|
||||
|
||||
### Staged (Sẵn sàng commit)
|
||||
- `HQLAND_PROJECT_BLUEPRINT.md`
|
||||
- `analyze_contracts.php`, `analyze_excel.php`, `analyze_khachhang.php`
|
||||
- `app/Console/Commands/ImportContractsComplex.php`
|
||||
- `app/Console/Commands/ImportCustomersExcel.php`
|
||||
- `app/Console/Commands/ImportProductsExcel.php`
|
||||
- `app/Filament/Resources/Contracts/Schemas/ContractForm.php`
|
||||
- `app/Filament/Resources/Contracts/Tables/ContractsTable.php`
|
||||
- `app/Filament/Resources/Customers/CustomerResource.php`
|
||||
- `app/Filament/Resources/Customers/Schemas/CustomerForm.php`
|
||||
- `app/Filament/Resources/Customers/Tables/CustomersTable.php`
|
||||
- `app/Filament/Resources/Products/Schemas/ProductForm.php`
|
||||
- `app/Models/Contract.php`
|
||||
- `app/Models/Customer.php`
|
||||
- `composer.json`, `composer.lock`
|
||||
- `database/migrations/2026_04_23_081206_update_customers_table_for_real_estate.php`
|
||||
- `database/migrations/2026_04_23_094837_expand_contracts_table_for_finance.php`
|
||||
- `tests/Feature/ContractFinanceFlowTest.php`
|
||||
- `tests/Feature/ProductResourceTest.php`
|
||||
|
||||
### Unstaged (Đang chỉnh sửa, chưa xong)
|
||||
- `.gitignore`
|
||||
- `analyze_contracts.php`
|
||||
- `app/Filament/Resources/Contracts/ContractResource.php` (thêm action Tạo lịch TT)
|
||||
- `app/Filament/Resources/Contracts/Pages/CreateContract.php` (refactor dùng Service)
|
||||
- `app/Filament/Resources/Contracts/Schemas/ContractForm.php`
|
||||
- `app/Filament/Resources/Projects/ProjectResource.php` (refactor sang Schemas)
|
||||
- `app/Filament/Resources/Projects/Schemas/ProjectForm.php`
|
||||
- `app/Models/Contract.php` (booted logic tài chính)
|
||||
- `app/Providers/AppServiceProvider.php` (đăng ký PaymentObserver)
|
||||
- `composer.json` (xóa script tạo SQLite)
|
||||
- `config/database.php` (default về pgsql)
|
||||
- `database/factories/CustomerFactory.php`
|
||||
- `phpunit.xml` (cấu hình PostgreSQL testing)
|
||||
|
||||
### Untracked (File mới chưa add)
|
||||
- `app/Filament/Resources/Payments/` (PaymentResource, Form, Table, Pages)
|
||||
- `app/Observers/PaymentObserver.php`
|
||||
- `app/Services/ContractScheduleService.php`
|
||||
|
||||
---
|
||||
|
||||
*File này cần được cập nhật mỗi khi có thay đổi lớn trong kiến trúc hoặc lộ trình phát triển.*
|
||||
74
HQLAND_PROJECT_BLUEPRINT.md
Normal file
74
HQLAND_PROJECT_BLUEPRINT.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# HQLAND PROJECT BLUEPRINT (V1.0)
|
||||
## Project Memory & Implementation Guide for AI Agents
|
||||
|
||||
### 1. TỔNG QUAN DỰ ÁN
|
||||
- **Tên dự án:** HQLand - Hệ thống quản lý Bất động sản tập trung.
|
||||
- **Mục tiêu:** Quản lý kho hàng (Warehouse), Khách hàng (CRM), Hợp đồng (Contracts) và Dòng tiền (Finance).
|
||||
- **Đặc thù:** Xử lý dữ liệu lớn từ Excel, quản lý lịch sử chuyển nhượng phức tạp (F0, F1, F2...), và tự động hóa lịch trình thanh toán.
|
||||
|
||||
---
|
||||
|
||||
### 2. KIẾN TRÚC KỸ THUẬT (TECH STACK)
|
||||
- **Framework:** Laravel 13.x.
|
||||
- **PHP Version:** 8.3 (Lưu ý: Một số thư viện Symfony v8.x yêu cầu PHP 8.4 nên đã được hạ cấp xuống v7.x để tương thích).
|
||||
- **Admin Panel:** Filament v5.5 (Mô phỏng release tương lai).
|
||||
- **Kiến trúc UI:** **Schemas Architecture** (Bắt buộc). Tách biệt định nghĩa Form/Table ra khỏi Resource.
|
||||
- **Database:** PostgreSQL.
|
||||
- **Quy chuẩn Database:**
|
||||
- Khóa chính: **UUID** (100% các bảng).
|
||||
- Naming: **snake_case** cho mọi bảng và cột.
|
||||
- Chuẩn logic: **Prisma Schema v2.3**.
|
||||
|
||||
---
|
||||
|
||||
### 3. CẤU TRÚC DỮ LIỆU CỐT LÕI (CORE DATA SCHEMA)
|
||||
|
||||
#### 3.1. Warehouse (Kho hàng)
|
||||
- **Project:** Quản lý dự án (Mã dự án, Tên dự án).
|
||||
- **Product:** Lô đất hoặc Căn hộ.
|
||||
- `product_type`: LAND, APARTMENT.
|
||||
- `infrastructure_status`: JSONB lưu trạng thái (Đường, Điện, Nước...).
|
||||
- `custom_data`: JSONB lưu thông số đặc thù (Mật độ xây dựng, block...).
|
||||
|
||||
#### 3.2. CRM (Khách hàng)
|
||||
- **Customer:** Hỗ trợ 2 loại `INDIVIDUAL` (Cá nhân) và `COMPANY` (Pháp nhân).
|
||||
- **Logic Địa chỉ:** Lưu "cứng" 2 cột `permanent_address` và `contact_address` (không dùng JSON cho địa chỉ chính).
|
||||
- **Logic Công ty:** Sử dụng tự tham chiếu (Self-referencing) qua `representative_id`. Một Công ty sẽ liên kết với một Khách hàng cá nhân đóng vai trò người đại diện.
|
||||
- **Số điện thoại:** `phone` (chính) và `secondary_phones` (JSONB - mảng số phụ).
|
||||
|
||||
#### 3.3. Contracts (Hợp đồng & Tài chính)
|
||||
- **Contract:** Trung tâm của hệ thống.
|
||||
- **Logic Chuyển nhượng (`transfer_order`):**
|
||||
- `0`: Chủ sở hữu hiện tại (Đang hiệu lực).
|
||||
- `1`: Hợp đồng gốc (F0).
|
||||
- `2+`: Các đời tiếp theo (F1, F2...).
|
||||
- **Tài chính:** Lưu snapshot giá trị tại thời điểm ký (`land_value`, `foundation_value`, `total_value`).
|
||||
- **Chiết khấu:** `discount_details` (JSONB) lưu mọi loại ưu đãi (Bán sỉ, mở bán, CTV...).
|
||||
|
||||
---
|
||||
|
||||
### 4. LOGIC NGHIỆP VỤ ĐÃ CÀI ĐẶT
|
||||
1. **Import Sản phẩm:** Tự động bóc tách chuỗi hạ tầng phức tạp từ Excel thành JSON có cấu trúc.
|
||||
2. **Import Khách hàng:** Tự động nhận diện 2 số điện thoại, định dạng ngày tháng Excel, và thiết lập quan hệ Công ty/Người đại diện.
|
||||
3. **Import Hợp đồng (Phức hợp):** Logic "Bắc cầu" giữa 2 file Excel (`hopdong.xlsx` và `Hd_kh.xlsx`) để nối Khách hàng - Hợp đồng - Sản phẩm dựa trên mã lô đất.
|
||||
4. **Surplus Logic:** (Đã thiết kế) Tiền nộp thừa sẽ lưu vào `excess_amount` để khấu trừ cho đợt sau.
|
||||
|
||||
---
|
||||
|
||||
### 5. BÀI HỌC KINH NGHIỆM & QUY TẮC PHÁT TRIỂN (CRITICAL)
|
||||
1. **UI Consistenty:** LUÔN sử dụng `Schemas` class. KHÔNG định nghĩa inline trong Resource.
|
||||
2. **Filament Layout:** Trong bản v5.5 này, `Grid` và `Section` nằm trong `Filament\Schemas\Components`, không phải `Filament\Forms\Components`.
|
||||
3. **Màu sắc UI:** Khi render HTML động trong Form (như bảng hạ tầng), sử dụng **Inline Styles** thay vì Tailwind class để tránh bị PurgeCSS loại bỏ.
|
||||
4. **Database Safety:** Tuyệt đối không dùng `migrate:fresh` trên môi trường đang có dữ liệu tài khoản người dùng (`taikhoan.txt`).
|
||||
5. **Data Casting:** Mọi trường JSONB trong Model phải được khai báo trong `$casts = ['field' => 'array']`.
|
||||
|
||||
---
|
||||
|
||||
### 6. LỘ TRÌNH PHÁT TRIỂN TIẾP THEO (NEXT STEPS)
|
||||
1. **Module Chiết khấu (Discounts):** Xây dựng engine tính toán dựa trên `discount_details`.
|
||||
2. **Tự động hóa Lịch trình:** Viết lệnh chạy ngầm để tạo `PaymentSchedule` cho 139 hợp đồng vừa import dựa trên `PaymentTemplate`.
|
||||
3. **Module Thu tiền (Payments):** Ghi nhận phiếu thu và tự động đối soát công nợ từng đợt.
|
||||
4. **Báo cáo (Reporting):** Dashboard thống kê doanh thu theo Dự án, dòng tiền dự kiến trong tương lai.
|
||||
|
||||
---
|
||||
*Blueprint created on April 23, 2026. Sync this file to all sub-agents before proceeding.*
|
||||
BIN
Hd_kh.xlsx
Normal file
BIN
Hd_kh.xlsx
Normal file
Binary file not shown.
24
analyze_contracts.php
Normal file
24
analyze_contracts.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
require 'vendor/autoload.php';
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
function analyze($file) {
|
||||
echo "--- ANALYZING: $file ---\n";
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($file);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $worksheet->toArray();
|
||||
echo "HEADERS:\n";
|
||||
if (isset($rows[0])) {
|
||||
foreach ($rows[0] as $idx => $h) echo "Col $idx: " . ($h ?? "NULL") . "\n";
|
||||
}
|
||||
echo "SAMPLE DATA (Row 2):\n";
|
||||
if (isset($rows[1])) echo implode(" | ", array_map(fn($v) => $v ?? "NULL", $rows[1])) . "\n";
|
||||
} catch (Exception $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
analyze('storage/app/imports/Hd_kh.xlsx');
|
||||
analyze('storage/app/imports/hopdong.xlsx');
|
||||
28
analyze_excel.php
Normal file
28
analyze_excel.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
$inputFileName = 'sanpham.xlsx';
|
||||
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($inputFileName);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $worksheet->toArray();
|
||||
|
||||
echo "--- HEADER COLUMNS ---\n";
|
||||
if (isset($rows[0])) {
|
||||
foreach ($rows[0] as $index => $header) {
|
||||
echo "Col " . $index . ": " . ($header ?? "NULL") . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- SAMPLE DATA (Rows 2-4) ---\n";
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
if (isset($rows[$i])) {
|
||||
echo "Row " . ($i + 1) . ": " . implode(" | ", array_map(fn($v) => $v ?? "NULL", $rows[$i])) . "\n";
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo 'Error loading file: ' . $e->getMessage();
|
||||
}
|
||||
28
analyze_khachhang.php
Normal file
28
analyze_khachhang.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
$inputFileName = 'khachhang.xlsx';
|
||||
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($inputFileName);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $worksheet->toArray();
|
||||
|
||||
echo "--- HEADER COLUMNS ---\n";
|
||||
if (isset($rows[0])) {
|
||||
foreach ($rows[0] as $index => $header) {
|
||||
echo "Col " . $index . ": " . ($header ?? "NULL") . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n--- SAMPLE DATA (Rows 2-5) ---\n";
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
if (isset($rows[$i])) {
|
||||
echo "Row " . ($i + 1) . ": " . implode(" | ", array_map(fn($v) => $v ?? "NULL", $rows[$i])) . "\n";
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo 'Error loading file: ' . $e->getMessage();
|
||||
}
|
||||
167
app/Console/Commands/ImportContractsComplex.php
Normal file
167
app/Console/Commands/ImportContractsComplex.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Product;
|
||||
use App\Models\Customer;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportContractsComplex extends Command
|
||||
{
|
||||
protected $signature = 'import:contracts-complex {hopdong=hopdong.xlsx} {hdkh=Hd_kh.xlsx}';
|
||||
protected $description = 'Import hợp đồng và liên kết khách hàng từ 2 file Excel';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$fileHopDong = $this->argument('hopdong');
|
||||
$fileHdKh = $this->argument('hdkh');
|
||||
|
||||
if (!file_exists($fileHopDong) || !file_exists($fileHdKh)) {
|
||||
$this->error("Không tìm thấy một trong hai file Excel.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// BƯỚC 1: ĐỌC FILE HOPDONG.XLSX ĐỂ LẤY DỮ LIỆU TÀI CHÍNH
|
||||
$this->info("Đang xử lý dữ liệu tài chính từ hopdong.xlsx...");
|
||||
$sheetFinance = IOFactory::load($fileHopDong)->getActiveSheet();
|
||||
$rowsFinance = $sheetFinance->toArray();
|
||||
$financeMap = [];
|
||||
|
||||
foreach ($rowsFinance as $idx => $row) {
|
||||
if ($idx === 0 || empty($row[2])) continue; // Bỏ qua header hoặc Số HĐMB trống
|
||||
|
||||
$contractNumber = trim($row[2]);
|
||||
$financeMap[$contractNumber] = [
|
||||
'signing_date' => $this->parseExcelDate($row[1]),
|
||||
'sale_date' => $this->parseExcelDate($row[3]),
|
||||
'hql_confirmation_date' => $this->parseExcelDate($row[4]),
|
||||
'brokerage_name' => $row[5],
|
||||
'land_value' => $this->parseMoney($row[9]),
|
||||
'foundation_value' => $this->parseMoney($row[10]),
|
||||
'total_value_with_foundation' => $this->parseMoney($row[11]),
|
||||
'stored_contract_count' => (int)$row[23],
|
||||
'filing_note' => $row[26],
|
||||
'discounts' => [
|
||||
'open_sale' => $row[13],
|
||||
'multi_lot' => $row[14],
|
||||
'wholesale' => $row[15],
|
||||
'ctv' => $row[16],
|
||||
'full_payment' => $row[17],
|
||||
'total_percentage' => $row[18],
|
||||
'total_amount' => $this->parseMoney($row[19]),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// BƯỚC 2: ĐỌC FILE HD_KH.XLSX ĐỂ TẠO HỢP ĐỒNG VÀ LIÊN KẾT
|
||||
$this->info("Đang xử lý liên kết khách hàng từ Hd_kh.xlsx...");
|
||||
$sheetLink = IOFactory::load($fileHdKh)->getActiveSheet();
|
||||
$rowsLink = $sheetLink->toArray();
|
||||
|
||||
$count = 0;
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($rowsLink as $idx => $row) {
|
||||
if ($idx === 0 || empty($row[2])) continue; // Bỏ qua header hoặc Mã Lô trống
|
||||
|
||||
$plotCode = trim($row[2]);
|
||||
$customerCmnd = trim($row[5]);
|
||||
$transferOrder = (int)$row[3];
|
||||
|
||||
// Tìm sản phẩm
|
||||
$product = Product::where('code', $plotCode)->first();
|
||||
if (!$product) {
|
||||
$this->warn("Bỏ qua: Không tìm thấy Lô {$plotCode} trong database.");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tìm khách hàng
|
||||
$customer = Customer::where('cmnd_cccd', $customerCmnd)->first();
|
||||
if (!$customer) {
|
||||
$this->warn("Bỏ qua: Không tìm thấy Khách hàng CMND {$customerCmnd} ({$row[6]}).");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Logic tìm Hợp đồng tương ứng trong financeMap
|
||||
// Vì hopdong.xlsx không có mã lô, ta sẽ tìm trong financeMap xem Số HĐMB nào có chứa mã lô này
|
||||
$targetContractNumber = null;
|
||||
$financeData = null;
|
||||
foreach ($financeMap as $number => $data) {
|
||||
if (str_contains($number, $plotCode)) {
|
||||
$targetContractNumber = $number;
|
||||
$financeData = $data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$targetContractNumber) {
|
||||
$this->warn("Lô {$plotCode}: Không tìm thấy thông tin tài chính trong hopdong.xlsx. Sẽ dùng mã tạm.");
|
||||
$targetContractNumber = "HD-TEMP-" . $plotCode . "-" . $transferOrder;
|
||||
}
|
||||
|
||||
// Tạo/Cập nhật Hợp đồng
|
||||
$contract = Contract::updateOrCreate(
|
||||
['contract_number' => $targetContractNumber],
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'signing_date' => $financeData['signing_date'] ?? null,
|
||||
'total_value' => $financeData['total_value_with_foundation'] ?? 0,
|
||||
'land_value' => $financeData['land_value'] ?? 0,
|
||||
'foundation_value' => $financeData['foundation_value'] ?? 0,
|
||||
'total_value_with_foundation' => $financeData['total_value_with_foundation'] ?? 0,
|
||||
'discount_details' => $financeData['discounts'] ?? [],
|
||||
'brokerage_name' => $financeData['brokerage_name'] ?? null,
|
||||
'sale_date' => $financeData['sale_date'] ?? null,
|
||||
'hql_confirmation_date' => $financeData['hql_confirmation_date'] ?? null,
|
||||
'stored_contract_count' => $financeData['stored_contract_count'] ?? 0,
|
||||
'filing_note' => $financeData['filing_note'] ?? null,
|
||||
'transfer_order' => $transferOrder,
|
||||
'contract_type' => 'HĐMB',
|
||||
'status' => 'Đang hiệu lực', // Tạm thời set mặc định
|
||||
]
|
||||
);
|
||||
|
||||
// Liên kết khách hàng (Pivot)
|
||||
$contract->customers()->syncWithoutDetaching([
|
||||
$customer->id => [
|
||||
'role' => $row[7] ?? 'Chủ SH',
|
||||
'transfer_order' => $transferOrder
|
||||
]
|
||||
]);
|
||||
|
||||
$count++;
|
||||
}
|
||||
DB::commit();
|
||||
$this->info("Thành công! Đã tạo và liên kết {$count} bản ghi hợp đồng.");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error("Lỗi: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function parseMoney($value)
|
||||
{
|
||||
if (empty($value)) return 0;
|
||||
return (float) str_replace([',', ' '], '', $value);
|
||||
}
|
||||
|
||||
private function parseExcelDate($value)
|
||||
{
|
||||
if (empty($value)) return null;
|
||||
try {
|
||||
if (is_numeric($value)) {
|
||||
return Carbon::instance(ExcelDate::excelToDateTimeObject($value))->format('Y-m-d');
|
||||
}
|
||||
return Carbon::parse(str_replace('/', '-', $value))->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
116
app/Console/Commands/ImportCustomersExcel.php
Normal file
116
app/Console/Commands/ImportCustomersExcel.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Customer;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ImportCustomersExcel extends Command
|
||||
{
|
||||
protected $signature = 'import:customers-excel {file=khachhang.xlsx}';
|
||||
protected $description = 'Import khách hàng từ file Excel và tạo dữ liệu mẫu Công ty';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$filePath = $this->argument('file');
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
$this->error("Không tìm thấy file: {$filePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Đang đọc file Excel...");
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $worksheet->toArray();
|
||||
|
||||
$count = 0;
|
||||
foreach ($rows as $index => $row) {
|
||||
if ($index === 0 || empty($row[1])) continue; // Bỏ qua header hoặc CMND trống
|
||||
|
||||
// 1. Xử lý số điện thoại (Tách nếu có nhiều số)
|
||||
$phoneRaw = $row[5] ?? '';
|
||||
$phones = preg_split('/[,\/ \n]+/', $phoneRaw, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$mainPhone = $phones[0] ?? null;
|
||||
$secondaryPhones = array_slice($phones, 1);
|
||||
|
||||
// 2. Xử lý ngày tháng (Excel thường lưu ngày là số serial)
|
||||
$dob = $this->parseExcelDate($row[4]);
|
||||
$issueDate = $this->parseExcelDate($row[7]);
|
||||
|
||||
Customer::updateOrCreate(
|
||||
['cmnd_cccd' => (string)$row[1]],
|
||||
[
|
||||
'title' => $row[2],
|
||||
'full_name' => $row[3],
|
||||
'dob' => $dob,
|
||||
'phone' => $mainPhone,
|
||||
'secondary_phones' => $secondaryPhones,
|
||||
'email' => $row[6],
|
||||
'id_issue_date' => $issueDate,
|
||||
'id_issue_place' => $row[8],
|
||||
'permanent_address' => $row[9],
|
||||
'contact_address' => $row[10],
|
||||
'type' => 'INDIVIDUAL',
|
||||
]
|
||||
);
|
||||
|
||||
$count++;
|
||||
if ($count % 10 === 0) $this->line("Đã import: {$count} khách hàng...");
|
||||
}
|
||||
|
||||
$this->info("--- TẠO DỮ LIỆU MẪU CÔNG TY ---");
|
||||
$this->createSampleCompany();
|
||||
|
||||
$this->info("Thành công! Đã import {$count} khách hàng và tạo 1 cặp Công ty + Người đại diện mẫu.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function parseExcelDate($value)
|
||||
{
|
||||
if (empty($value)) return null;
|
||||
try {
|
||||
if (is_numeric($value)) {
|
||||
return Carbon::instance(ExcelDate::excelToDateTimeObject($value))->format('Y-m-d');
|
||||
}
|
||||
return Carbon::parse(str_replace('/', '-', $value))->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function createSampleCompany()
|
||||
{
|
||||
// 1. Tạo người đại diện (Cá nhân)
|
||||
$rep = Customer::updateOrCreate(
|
||||
['cmnd_cccd' => '079083000123'],
|
||||
[
|
||||
'title' => 'Ông',
|
||||
'full_name' => 'NGUYỄN VĂN ĐẠI DIỆN',
|
||||
'phone' => '0909123456',
|
||||
'permanent_address' => '123 Đường ABC, Phường 1, Quận 1, TP.HCM',
|
||||
'contact_address' => '123 Đường ABC, Phường 1, Quận 1, TP.HCM',
|
||||
'type' => 'INDIVIDUAL',
|
||||
]
|
||||
);
|
||||
|
||||
// 2. Tạo công ty liên kết với người đại diện trên
|
||||
Customer::updateOrCreate(
|
||||
['tax_code' => '0102030405'],
|
||||
[
|
||||
'type' => 'COMPANY',
|
||||
'full_name' => 'CÔNG TY TNHH BẤT ĐỘNG SẢN THỊNH VƯỢNG',
|
||||
'cmnd_cccd' => '0102030405', // GPKD
|
||||
'representative_id' => $rep->id,
|
||||
'permanent_address' => '456 Đường XYZ, Phường 2, Quận Tân Bình, TP.HCM', // Trụ sở chính
|
||||
'contact_address' => '456 Đường XYZ, Phường 2, Quận Tân Bình, TP.HCM',
|
||||
'phone' => '02838111222',
|
||||
]
|
||||
);
|
||||
|
||||
$this->info("Đã tạo: Công ty Thịnh Vượng (Đại diện bởi: {$rep->full_name})");
|
||||
}
|
||||
}
|
||||
118
app/Console/Commands/ImportProductsExcel.php
Normal file
118
app/Console/Commands/ImportProductsExcel.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Project;
|
||||
use App\Models\Product;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImportProductsExcel extends Command
|
||||
{
|
||||
protected $signature = 'import:products-excel {file=sanpham.xlsx}';
|
||||
protected $description = 'Import sản phẩm từ file Excel vào dự án Hà Quang 1';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$filePath = $this->argument('file');
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
$this->error("Không tìm thấy file: {$filePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Đang đọc file Excel...");
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $worksheet->toArray();
|
||||
|
||||
// 1. Đảm bảo có dự án Hà Quang 1
|
||||
$project = Project::firstOrCreate(
|
||||
['name' => 'Hà Quang 1'],
|
||||
['code' => 'HQ1']
|
||||
);
|
||||
|
||||
$this->info("Dự án: {$project->name} (ID: {$project->id})");
|
||||
|
||||
// 2. Duyệt dữ liệu (bỏ qua dòng tiêu đề)
|
||||
$count = 0;
|
||||
foreach ($rows as $index => $row) {
|
||||
if ($index === 0 || empty($row[2])) continue; // Bỏ qua header hoặc dòng trống mã lô
|
||||
|
||||
$code = $row[2];
|
||||
|
||||
// Chuẩn hóa số
|
||||
$area = (float) $row[3];
|
||||
$price_per_unit = $this->parseMoney($row[4]);
|
||||
$total_price = $this->parseMoney($row[5]);
|
||||
$qsdd_value = $this->parseMoney($row[6]);
|
||||
$foundation_temp_value = $this->parseMoney($row[7]);
|
||||
$contract_temp_value = $this->parseMoney($row[8]);
|
||||
|
||||
// Phân tách hạ tầng (JSONB)
|
||||
$infraRaw = $row[14] ?? '';
|
||||
$infraJson = $this->parseInfrastructure($infraRaw);
|
||||
|
||||
// Custom data
|
||||
$customData = [
|
||||
'block' => $row[1],
|
||||
'building_density' => $row[12],
|
||||
'legal_status_raw' => $row[15],
|
||||
'summary_legal' => $row[19],
|
||||
];
|
||||
|
||||
Product::updateOrCreate(
|
||||
['code' => $code, 'project_id' => $project->id],
|
||||
[
|
||||
'product_type' => 'LAND', // Mặc định là đất nền theo file
|
||||
'area' => $area,
|
||||
'price_per_unit' => $price_per_unit,
|
||||
'total_price' => $total_price,
|
||||
'qsdd_value' => $qsdd_value,
|
||||
'foundation_temp_value' => $foundation_temp_value,
|
||||
'contract_temp_value' => $contract_temp_value,
|
||||
'adjacent_road' => $row[9],
|
||||
'frontage_count' => (int) $row[10],
|
||||
'max_floors' => (int) $row[11],
|
||||
'construction_status' => $row[13] ?? 'Chưa xây dựng',
|
||||
'infrastructure_status' => $infraJson,
|
||||
'custom_data' => $customData,
|
||||
'status' => 'Đang mở bán',
|
||||
]
|
||||
);
|
||||
|
||||
$count++;
|
||||
if ($count % 10 === 0) $this->line("Đã import: {$count} sản phẩm...");
|
||||
}
|
||||
|
||||
$this->info("Thành công! Đã import tổng cộng {$count} sản phẩm vào dự án Hà Quang 1.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function parseMoney($value)
|
||||
{
|
||||
if (empty($value)) return 0;
|
||||
// Xóa dấu phẩy và khoảng trắng
|
||||
return (float) str_replace([',', ' '], '', $value);
|
||||
}
|
||||
|
||||
private function parseInfrastructure($raw)
|
||||
{
|
||||
if (empty($raw)) return [];
|
||||
|
||||
$result = [];
|
||||
// Tách theo dấu gạch ngang " - "
|
||||
$parts = explode(' - ', $raw);
|
||||
foreach ($parts as $part) {
|
||||
// Tách theo dấu hai chấm ":"
|
||||
$subParts = explode(':', $part, 2);
|
||||
if (count($subParts) === 2) {
|
||||
$key = trim($subParts[0]);
|
||||
$value = trim($subParts[1]);
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,13 @@ namespace App\Filament\Resources\Contracts;
|
||||
|
||||
use App\Filament\Resources\Contracts\Pages;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Product;
|
||||
use App\Models\PaymentTemplate;
|
||||
use App\Enums\NavigationGroup;
|
||||
use App\Services\ContractScheduleService;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use App\Filament\Resources\Contracts\ContractResource\RelationManagers\ScheduleItemsRelationManager;
|
||||
|
||||
use App\Filament\Resources\Contracts\Schemas\ContractForm;
|
||||
|
||||
class ContractResource extends Resource
|
||||
@@ -37,6 +35,27 @@ class ContractResource extends Resource
|
||||
Tables\Columns\TextColumn::make('contract_number')->label('Số HĐ')->searchable(),
|
||||
Tables\Columns\TextColumn::make('product.code')->label('Sản phẩm'),
|
||||
Tables\Columns\TextColumn::make('total_value')->label('Giá trị')->money('VND'),
|
||||
Tables\Columns\TextColumn::make('paid_amount')->label('Đã thu')->money('VND'),
|
||||
Tables\Columns\TextColumn::make('remaining_amount')->label('Còn lại')->money('VND'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\Action::make('generateSchedule')
|
||||
->label('Tạo lịch TT')
|
||||
->icon('heroicon-o-calendar-days')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Tạo lịch thanh toán')
|
||||
->modalDescription('Hành động này sẽ xóa lịch thanh toán cũ (nếu có) và tạo lại từ mẫu của dự án.')
|
||||
->action(function (Contract $record) {
|
||||
try {
|
||||
ContractScheduleService::generateFromTemplate($record);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Filament sẽ tự động hiển thị lỗi nếu throw ra trong action
|
||||
throw $e;
|
||||
}
|
||||
})
|
||||
->visible(fn (Contract $record) => $record->signing_date !== null),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
namespace App\Filament\Resources\Contracts\Pages;
|
||||
|
||||
use App\Filament\Resources\Contracts\ContractResource;
|
||||
use App\Models\PaymentTemplate;
|
||||
use App\Models\PaymentSchedule;
|
||||
use App\Models\PaymentScheduleItem;
|
||||
use App\Services\ContractScheduleService;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CreateContract extends CreateRecord
|
||||
{
|
||||
@@ -19,42 +16,9 @@ class CreateContract extends CreateRecord
|
||||
$templateId = $this->data['payment_template_id'] ?? null;
|
||||
|
||||
if ($templateId) {
|
||||
$template = PaymentTemplate::find($templateId);
|
||||
|
||||
// 1. Tạo Schedule cho Hợp đồng
|
||||
$schedule = PaymentSchedule::create([
|
||||
'contract_id' => $contract->id,
|
||||
'template_id' => $template->id,
|
||||
]);
|
||||
|
||||
// 2. Clone từng Item từ Template sang Schedule thực tế
|
||||
$items = $template->items()->orderBy('installment_no')->get();
|
||||
$lastDueDate = Carbon::parse($contract->signing_date);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$dueDate = null;
|
||||
|
||||
// Logic tính ngày đến hạn chuẩn v5.5
|
||||
if ($item->days_after_signing !== null) {
|
||||
$dueDate = Carbon::parse($contract->signing_date)->addDays($item->days_after_signing);
|
||||
} elseif ($item->days_after_previous !== null) {
|
||||
$dueDate = $lastDueDate->copy()->addDays($item->days_after_previous);
|
||||
} elseif ($item->due_date !== null) {
|
||||
$dueDate = $item->due_date;
|
||||
}
|
||||
|
||||
PaymentScheduleItem::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'installment_no' => $item->installment_no,
|
||||
'type' => $item->type,
|
||||
'percentage' => $item->percentage,
|
||||
'amount' => $contract->total_value * ($item->percentage / 100),
|
||||
'due_date' => $dueDate,
|
||||
]);
|
||||
|
||||
if ($dueDate) {
|
||||
$lastDueDate = $dueDate;
|
||||
}
|
||||
$template = \App\Models\PaymentTemplate::find($templateId);
|
||||
if ($template) {
|
||||
ContractScheduleService::generateFromTemplate($contract, $template);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ContractForm
|
||||
{
|
||||
@@ -18,12 +21,17 @@ class ContractForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Liên kết & Mẫu thanh toán')
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
Section::make('Thông tin định danh')
|
||||
->columnSpan(2)
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('product_id')
|
||||
->label('Sản phẩm')
|
||||
->label('Sản phẩm (Lô đất)')
|
||||
->relationship('product', 'code')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(function (Set $set, $state) {
|
||||
@@ -31,42 +39,36 @@ class ContractForm
|
||||
$product = Product::find($state);
|
||||
if ($product) {
|
||||
$set('total_value', $product->total_price);
|
||||
$set('land_value', $product->qsdd_value);
|
||||
$set('foundation_value', $product->foundation_temp_value);
|
||||
}
|
||||
}
|
||||
}),
|
||||
Select::make('payment_template_id')
|
||||
->label('Mẫu thanh toán')
|
||||
->options(fn () => PaymentTemplate::pluck('name', 'id'))
|
||||
TextInput::make('contract_number')
|
||||
->label('Số HĐMB')
|
||||
->required()
|
||||
->dehydrated(false),
|
||||
Select::make('customers')
|
||||
->label('Khách hàng')
|
||||
->relationship('customers', 'full_name')
|
||||
->multiple()
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Chi tiết Hợp đồng')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('contract_number')->label('Số HĐ')->required(),
|
||||
->unique(ignoreRecord: true),
|
||||
Select::make('contract_type')
|
||||
->label('Loại HĐ')
|
||||
->label('Loại hợp đồng')
|
||||
->options([
|
||||
'HĐMB' => 'HĐMB',
|
||||
'HĐGV' => 'HĐGV',
|
||||
'HĐDC' => 'HĐDC',
|
||||
'HĐMB' => 'Hợp đồng mua bán',
|
||||
'HĐGV' => 'Hợp đồng góp vốn',
|
||||
'HĐDC' => 'Hợp đồng đặt cọc',
|
||||
])
|
||||
->default('HĐMB')
|
||||
->required(),
|
||||
DatePicker::make('signing_date')->label('Ngày ký')->required(),
|
||||
TextInput::make('total_value')
|
||||
->label('Giá trị HĐ')
|
||||
TextInput::make('transfer_order')
|
||||
->label('Thứ tự chuyển nhượng')
|
||||
->numeric()
|
||||
->required()
|
||||
->prefix('VND'),
|
||||
->default(0)
|
||||
->helperText('0 là chủ hiện tại, 1 là F0, 2 là F1...'),
|
||||
]),
|
||||
|
||||
Section::make('Trạng thái')
|
||||
->columnSpan(1)
|
||||
->schema([
|
||||
Select::make('status')
|
||||
->label('Trạng thái')
|
||||
->label('Trạng thái pháp lý')
|
||||
->options([
|
||||
'Đang hiệu lực' => 'Đang hiệu lực',
|
||||
'Đã hoàn thành' => 'Đã hoàn thành',
|
||||
@@ -74,7 +76,99 @@ class ContractForm
|
||||
])
|
||||
->default('Đang hiệu lực')
|
||||
->required(),
|
||||
])
|
||||
DatePicker::make('signing_date')
|
||||
->label('Ngày ký HĐ')
|
||||
->required(),
|
||||
DatePicker::make('sale_date')
|
||||
->label('Ngày bán thực tế'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Chi tiết Tài chính & Chiết khấu')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextInput::make('land_value')
|
||||
->label('Giá trị QSDĐ')
|
||||
->numeric()
|
||||
->prefix('VND')
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, $get, $set) => $set('total_value', (float)$state + (float)$get('foundation_value'))),
|
||||
TextInput::make('foundation_value')
|
||||
->label('Giá trị Móng')
|
||||
->numeric()
|
||||
->prefix('VND')
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, $get, $set) => $set('total_value', (float)$get('land_value') + (float)$state)),
|
||||
TextInput::make('total_value')
|
||||
->label('Tổng giá trị niêm yết')
|
||||
->numeric()
|
||||
->prefix('VND')
|
||||
->readOnly(),
|
||||
|
||||
Placeholder::make('discount_overview')
|
||||
->label('Tổng quan chiết khấu (Dữ liệu từ Excel)')
|
||||
->columnSpanFull()
|
||||
->content(function ($record) {
|
||||
if (!$record || !$record->discount_details) return 'Không có chiết khấu';
|
||||
$details = $record->discount_details;
|
||||
$html = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; background: #f9fafb; padding: 15px; border-radius: 8px; border: 1px solid #e5e7eb;">';
|
||||
foreach ($details as $key => $val) {
|
||||
if (empty($val)) continue;
|
||||
$label = match($key) {
|
||||
'open_sale' => 'Mở bán',
|
||||
'multi_lot' => 'Số nhiều',
|
||||
'wholesale' => 'Mua sỉ',
|
||||
'ctv' => 'Cộng tác viên',
|
||||
'full_payment' => 'Trả 1 lần',
|
||||
'total_amount' => 'Tổng tiền CK',
|
||||
'total_percentage' => 'Tổng % CK',
|
||||
default => $key
|
||||
};
|
||||
$style = str_contains($key, 'total') ? 'font-weight: bold; color: #16a34a;' : 'color: #4b5563;';
|
||||
$html .= "<div>
|
||||
<div style='font-size: 0.7rem; color: #9ca3af; text-transform: uppercase; margin-bottom: 4px;'>{$label}</div>
|
||||
<div style='{$style} font-size: 0.9rem;'>{$val}</div>
|
||||
</div>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
return new HtmlString($html);
|
||||
}),
|
||||
|
||||
KeyValue::make('discount_details')
|
||||
->label('Bảng chi tiết chiết khấu (Dạng Key-Value)')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make('Thông tin quản lý & Khách hàng')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('customers')
|
||||
->label('Khách hàng đứng tên')
|
||||
->multiple()
|
||||
->relationship('customers', 'full_name')
|
||||
->preload()
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
TextInput::make('brokerage_name')
|
||||
->label('Đơn vị môi giới'),
|
||||
DatePicker::make('hql_confirmation_date')
|
||||
->label('Ngày HQL xác nhận'),
|
||||
TextInput::make('stored_contract_count')
|
||||
->label('Số lượng HĐ lưu')
|
||||
->numeric()
|
||||
->default(0),
|
||||
TextInput::make('filing_note')
|
||||
->label('Ghi chú hồ sơ')
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('payment_template_id')
|
||||
->label('Áp dụng mẫu thanh toán')
|
||||
->placeholder('Chọn mẫu để tự động tạo lịch trình...')
|
||||
->options(PaymentTemplate::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->dehydrated(false)
|
||||
->helperText('Lưu ý: Chỉ chọn nếu bạn muốn khởi tạo lại lịch trình thanh toán.'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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\Columns\BadgeColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ContractsTable
|
||||
@@ -13,18 +15,70 @@ class ContractsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
//
|
||||
TextColumn::make('contract_number')
|
||||
->label('Số HĐMB')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->copyable()
|
||||
->description(fn ($record) => "Lô: {$record->product?->code}"),
|
||||
|
||||
TextColumn::make('customers.full_name')
|
||||
->label('Khách hàng')
|
||||
->searchable()
|
||||
->listWithLineBreaks()
|
||||
->bulleted(),
|
||||
|
||||
TextColumn::make('signing_date')
|
||||
->label('Ngày ký')
|
||||
->date('d/m/Y')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('total_value')
|
||||
->label('Giá trị HĐ')
|
||||
->money('VND')
|
||||
->sortable()
|
||||
->summarize(\Filament\Tables\Columns\Summarizers\Sum::make()->label('Tổng doanh thu')->money('VND')),
|
||||
|
||||
TextColumn::make('transfer_order')
|
||||
->label('Đời CN')
|
||||
->badge()
|
||||
->color(fn ($state) => $state == 0 ? 'success' : 'gray')
|
||||
->formatStateUsing(fn ($state) => $state == 0 ? 'Hiện tại' : "F{$state}")
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label('Trạng thái')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'Đang hiệu lực' => 'success',
|
||||
'Đã hoàn thành' => 'primary',
|
||||
'Đã hủy' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
\Filament\Tables\Filters\SelectFilter::make('status')
|
||||
->label('Trạng thái')
|
||||
->options([
|
||||
'Đang hiệu lực' => 'Đang hiệu lực',
|
||||
'Đã hoàn thành' => 'Đã hoàn thành',
|
||||
'Đã hủy' => 'Đã hủy',
|
||||
]),
|
||||
\Filament\Tables\Filters\TernaryFilter::make('is_current')
|
||||
->label('Chủ sở hữu hiện tại')
|
||||
->queries(
|
||||
true: fn ($query) => $query->where('transfer_order', 0),
|
||||
false: fn ($query) => $query->where('transfer_order', '>', 0),
|
||||
)
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,12 @@ namespace App\Filament\Resources\Customers;
|
||||
|
||||
use App\Filament\Resources\Customers\Pages;
|
||||
use App\Models\Customer;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Fieldset;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
use App\Enums\NavigationGroup;
|
||||
use App\Filament\Resources\Customers\Schemas\CustomerForm;
|
||||
use App\Filament\Resources\Customers\Tables\CustomersTable;
|
||||
|
||||
class CustomerResource extends Resource
|
||||
{
|
||||
@@ -22,47 +18,17 @@ class CustomerResource extends Resource
|
||||
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value;
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static ?string $modelLabel = 'Khách hàng';
|
||||
protected static ?string $pluralModelLabel = 'Khách hàng';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Thông tin định danh')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('full_name')->label('Họ và Tên')->required(),
|
||||
TextInput::make('cmnd_cccd')->label('Số CMND / CCCD')->required()->unique(ignoreRecord: true),
|
||||
DatePicker::make('dob')->label('Ngày sinh')->displayFormat('d/m/Y'),
|
||||
]),
|
||||
Section::make('Thông liên lạc')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('phone')->label('Số điện thoại')->tel()->required(),
|
||||
TextInput::make('email')->label('Email')->email(),
|
||||
]),
|
||||
Section::make('Địa chỉ chi tiết')
|
||||
->schema([
|
||||
Fieldset::make('address')
|
||||
->label('Cấu trúc địa chỉ')->columns(3)
|
||||
->schema([
|
||||
TextInput::make('address.street')->label('Số nhà, đường')->columnSpan(3),
|
||||
TextInput::make('address.ward')->label('Phường / Xã'),
|
||||
TextInput::make('address.district')->label('Quận / Huyện'),
|
||||
TextInput::make('address.city')->label('Tỉnh / Thành phố'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
return CustomerForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('full_name')->label('Họ Tên')->searchable(),
|
||||
Tables\Columns\TextColumn::make('cmnd_cccd')->label('CMND/CCCD')->searchable(),
|
||||
Tables\Columns\TextColumn::make('phone')->label('Điện thoại'),
|
||||
Tables\Columns\TextColumn::make('address.city')->label('Tỉnh/Thành')->sortable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
return CustomersTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
|
||||
namespace App\Filament\Resources\Customers\Schemas;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Actions\Action;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
|
||||
class CustomerForm
|
||||
{
|
||||
@@ -12,20 +18,75 @@ class CustomerForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Thông tin định danh')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('type')
|
||||
->label('Loại khách hàng')
|
||||
->options([
|
||||
'INDIVIDUAL' => 'Cá nhân',
|
||||
'COMPANY' => 'Công ty',
|
||||
])
|
||||
->required()
|
||||
->live(),
|
||||
TextInput::make('full_name')
|
||||
->label(fn ($get) => $get('type') === 'COMPANY' ? 'Tên công ty' : 'Họ và tên')
|
||||
->required(),
|
||||
TextInput::make('cmnd_cccd')
|
||||
->label(fn ($get) => $get('type') === 'COMPANY' ? 'GPKD / Mã số thuế' : 'CMND / CCCD')
|
||||
->required(),
|
||||
TextInput::make('tax_code')
|
||||
->label('Mã số thuế')
|
||||
->visible(fn ($get) => $get('type') === 'COMPANY'),
|
||||
Select::make('representative_id')
|
||||
->label('Người đại diện pháp luật')
|
||||
->options(Customer::where('type', 'INDIVIDUAL')->pluck('full_name', 'id'))
|
||||
->searchable()
|
||||
->visible(fn ($get) => $get('type') === 'COMPANY')
|
||||
->required(fn ($get) => $get('type') === 'COMPANY'),
|
||||
]),
|
||||
|
||||
Section::make('Liên lạc')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('phone')
|
||||
->label('Số điện thoại chính')
|
||||
->tel(),
|
||||
TagsInput::make('secondary_phones')
|
||||
->label('Số điện thoại phụ')
|
||||
->placeholder('Nhập số và nhấn Enter'),
|
||||
TextInput::make('email')
|
||||
->label('Email address')
|
||||
->label('Địa chỉ Email')
|
||||
->email(),
|
||||
TextInput::make('address_permanent'),
|
||||
TextInput::make('address_contact'),
|
||||
DatePicker::make('dob'),
|
||||
DatePicker::make('id_issue_date'),
|
||||
TextInput::make('id_issue_place'),
|
||||
]),
|
||||
|
||||
Section::make('Địa chỉ')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('permanent_address')
|
||||
->label('Địa chỉ thường trú / Trụ sở')
|
||||
->required()
|
||||
->suffixAction(
|
||||
Action::make('clone_to_contact')
|
||||
->label('Copy sang liên hệ')
|
||||
->icon('heroicon-m-arrow-right-start-on-rectangle')
|
||||
->action(function (Set $set, $state) {
|
||||
$set('contact_address', $state);
|
||||
})
|
||||
),
|
||||
TextInput::make('contact_address')
|
||||
->label('Địa chỉ liên hệ')
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Section::make('Thông tin bổ sung')
|
||||
->columns(3)
|
||||
->visible(fn ($get) => $get('type') === 'INDIVIDUAL')
|
||||
->schema([
|
||||
DatePicker::make('dob')->label('Ngày sinh'),
|
||||
DatePicker::make('id_issue_date')->label('Ngày cấp CMND'),
|
||||
TextInput::make('id_issue_place')->label('Nơi cấp'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CustomersTable
|
||||
@@ -14,45 +15,60 @@ class CustomersTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('ID'),
|
||||
TextColumn::make('full_name')
|
||||
->searchable(),
|
||||
TextColumn::make('cmnd_cccd')
|
||||
->searchable(),
|
||||
TextColumn::make('phone')
|
||||
->searchable(),
|
||||
TextColumn::make('email')
|
||||
->label('Email address')
|
||||
->searchable(),
|
||||
TextColumn::make('address_permanent')
|
||||
->searchable(),
|
||||
TextColumn::make('address_contact')
|
||||
->searchable(),
|
||||
TextColumn::make('dob')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('id_issue_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('id_issue_place')
|
||||
->searchable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->label('Họ tên / Công ty')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->description(fn ($record) => $get_desc = $record->type === 'COMPANY' ? "ĐD: {$record->representative?->full_name}" : $record->cmnd_cccd),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label('Loại')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'COMPANY' => 'warning',
|
||||
'INDIVIDUAL' => 'success',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'COMPANY' => 'Công ty',
|
||||
'INDIVIDUAL' => 'Cá nhân',
|
||||
default => $state,
|
||||
}),
|
||||
|
||||
TextColumn::make('phone')
|
||||
->label('Điện thoại')
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('permanent_address')
|
||||
->label('Địa chỉ thường trú')
|
||||
->limit(30)
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('contact_address')
|
||||
->label('Địa chỉ liên hệ')
|
||||
->limit(30)
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Ngày tạo')
|
||||
->dateTime('d/m/Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
\Filament\Tables\Filters\SelectFilter::make('type')
|
||||
->label('Loại khách hàng')
|
||||
->options([
|
||||
'INDIVIDUAL' => 'Cá nhân',
|
||||
'COMPANY' => 'Công ty',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
|
||||
11
app/Filament/Resources/Payments/Pages/CreatePayment.php
Normal file
11
app/Filament/Resources/Payments/Pages/CreatePayment.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Payments\Pages;
|
||||
|
||||
use App\Filament\Resources\Payments\PaymentResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePayment extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PaymentResource::class;
|
||||
}
|
||||
19
app/Filament/Resources/Payments/Pages/EditPayment.php
Normal file
19
app/Filament/Resources/Payments/Pages/EditPayment.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Payments\Pages;
|
||||
|
||||
use App\Filament\Resources\Payments\PaymentResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPayment extends EditRecord
|
||||
{
|
||||
protected static string $resource = PaymentResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Payments/Pages/ListPayments.php
Normal file
19
app/Filament/Resources/Payments/Pages/ListPayments.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Payments\Pages;
|
||||
|
||||
use App\Filament\Resources\Payments\PaymentResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPayments extends ListRecords
|
||||
{
|
||||
protected static string $resource = PaymentResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Filament/Resources/Payments/PaymentResource.php
Normal file
42
app/Filament/Resources/Payments/PaymentResource.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Payments;
|
||||
|
||||
use App\Filament\Resources\Payments\Pages;
|
||||
use App\Models\Payment;
|
||||
use App\Enums\NavigationGroup;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Table;
|
||||
use App\Filament\Resources\Payments\Schemas\PaymentForm;
|
||||
use App\Filament\Resources\Payments\Tables\PaymentsTable;
|
||||
|
||||
class PaymentResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Payment::class;
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes';
|
||||
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;
|
||||
protected static ?int $navigationSort = 5;
|
||||
|
||||
protected static ?string $modelLabel = 'Phiếu thu';
|
||||
protected static ?string $pluralModelLabel = 'Phiếu thu';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return PaymentForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return PaymentsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListPayments::route('/'),
|
||||
'create' => Pages\CreatePayment::route('/create'),
|
||||
'edit' => Pages\EditPayment::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
98
app/Filament/Resources/Payments/Schemas/PaymentForm.php
Normal file
98
app/Filament/Resources/Payments/Schemas/PaymentForm.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Payments\Schemas;
|
||||
|
||||
use App\Models\PaymentScheduleItem;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
|
||||
class PaymentForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
Section::make('Thông tin phiếu thu')
|
||||
->columnSpan(2)
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('contract_id')
|
||||
->label('Hợp đồng')
|
||||
->relationship('contract', 'contract_number')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(function (Set $set) {
|
||||
$set('schedule_item_id', null);
|
||||
}),
|
||||
|
||||
Select::make('schedule_item_id')
|
||||
->label('Đợt thanh toán')
|
||||
->placeholder('Để trống nếu là tạm ứng / không đối soát đợt')
|
||||
->options(function (callable $get) {
|
||||
$contractId = $get('contract_id');
|
||||
if (! $contractId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PaymentScheduleItem::query()
|
||||
->whereHas('schedule', fn ($q) => $q->where('contract_id', $contractId))
|
||||
->get()
|
||||
->mapWithKeys(function ($item) {
|
||||
$label = 'Đợt '.$item->installment_no.' - '.$item->type;
|
||||
if ($item->amount) {
|
||||
$label .= ' ('.number_format($item->amount).' VNĐ)';
|
||||
}
|
||||
|
||||
return [$item->id => $label];
|
||||
});
|
||||
})
|
||||
->searchable(),
|
||||
|
||||
TextInput::make('amount')
|
||||
->label('Số tiền thu')
|
||||
->numeric()
|
||||
->prefix('VND')
|
||||
->required(),
|
||||
|
||||
DatePicker::make('paid_date')
|
||||
->label('Ngày thu')
|
||||
->required()
|
||||
->default(now()),
|
||||
|
||||
TextInput::make('receipt_number')
|
||||
->label('Số phiếu thu / Mã giao dịch'),
|
||||
|
||||
Select::make('method')
|
||||
->label('Phương thức thanh toán')
|
||||
->options([
|
||||
'Chuyển khoản' => 'Chuyển khoản',
|
||||
'Tiền mặt' => 'Tiền mặt',
|
||||
'Thẻ' => 'Thẻ',
|
||||
'Khác' => 'Khác',
|
||||
])
|
||||
->default('Chuyển khoản')
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Section::make('Bổ sung')
|
||||
->columnSpan(1)
|
||||
->schema([
|
||||
KeyValue::make('metadata')
|
||||
->label('Dữ liệu bổ sung (nếu có)')
|
||||
->keyLabel('Thông tin')
|
||||
->valueLabel('Giá trị'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
app/Filament/Resources/Payments/Tables/PaymentsTable.php
Normal file
59
app/Filament/Resources/Payments/Tables/PaymentsTable.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Payments\Tables;
|
||||
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PaymentsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('contract.contract_number')
|
||||
->label('Hợp đồng')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('amount')
|
||||
->label('Số tiền')
|
||||
->money('VND')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('paid_date')
|
||||
->label('Ngày thu')
|
||||
->date('d/m/Y')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('method')
|
||||
->label('Phương thức')
|
||||
->badge(),
|
||||
Tables\Columns\TextColumn::make('receipt_number')
|
||||
->label('Số phiếu thu')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('scheduleItem.installment_no')
|
||||
->label('Đợt TT')
|
||||
->placeholder('Tạm ứng'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('method')
|
||||
->label('Phương thức')
|
||||
->options([
|
||||
'Chuyển khoản' => 'Chuyển khoản',
|
||||
'Tiền mặt' => 'Tiền mặt',
|
||||
'Thẻ' => 'Thẻ',
|
||||
'Khác' => 'Khác',
|
||||
]),
|
||||
Tables\Filters\Filter::make('paid_date')
|
||||
->label('Ngày thu')
|
||||
->form([
|
||||
\Filament\Forms\Components\DatePicker::make('from')->label('Từ ngày'),
|
||||
\Filament\Forms\Components\DatePicker::make('to')->label('Đến ngày'),
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
return $query
|
||||
->when($data['from'], fn ($q) => $q->whereDate('paid_date', '>=', $data['from']))
|
||||
->when($data['to'], fn ($q) => $q->whereDate('paid_date', '<=', $data['to']));
|
||||
}),
|
||||
])
|
||||
->defaultSort('paid_date', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ProductForm
|
||||
{
|
||||
@@ -49,29 +52,64 @@ class ProductForm
|
||||
]),
|
||||
Tabs\Tab::make('Kỹ thuật & Hạ tầng')
|
||||
->icon('heroicon-o-cog')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Section::make('Thông số kỹ thuật')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextInput::make('adjacent_road')->label('Đường tiếp giáp'),
|
||||
TextInput::make('frontage_count')->label('Số mặt tiền')->numeric(),
|
||||
TextInput::make('max_floors')->label('Số tầng tối đa')->numeric(),
|
||||
TextInput::make('construction_status')->label('Trạng thái XD'),
|
||||
TextInput::make('max_floors')->label('Số tầng cao')->numeric(),
|
||||
]),
|
||||
Section::make('Chi tiết hạ tầng thực tế')
|
||||
->description('Trình trạng hạ tầng kỹ thuật thực tế tại lô đất')
|
||||
->schema([
|
||||
Placeholder::make('infra_overview')
|
||||
->label('Tổng quan trạng thái (Tự động cập nhật)')
|
||||
->content(function ($record) {
|
||||
if (!$record || !$record->infrastructure_status || !is_array($record->infrastructure_status)) {
|
||||
return new HtmlString('<span style="color: #6b7280;">Chưa có dữ liệu hạ tầng</span>');
|
||||
}
|
||||
|
||||
$html = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; background: #f9fafb; padding: 15px; border-radius: 8px; border: 1px solid #e5e7eb;">';
|
||||
|
||||
foreach ($record->infrastructure_status as $key => $status) {
|
||||
$statusLower = mb_strtolower($status);
|
||||
$color = '#6b7280'; // Mặc định xám
|
||||
$icon = '○';
|
||||
|
||||
if (str_contains($statusLower, 'hoàn thành') || str_contains($statusLower, 'đã bàn giao')) {
|
||||
$color = '#16a34a'; // Xanh lá
|
||||
$icon = '●';
|
||||
} elseif (str_contains($statusLower, 'đang thi công') || str_contains($statusLower, 'đang triển khai')) {
|
||||
$color = '#ca8a04'; // Vàng cam
|
||||
$icon = '◐';
|
||||
}
|
||||
|
||||
$html .= "<div style='display: flex; align-items: center; gap: 8px;'>
|
||||
<span style='color: {$color}; font-size: 1.2rem;'>{$icon}</span>
|
||||
<div>
|
||||
<div style='font-size: 0.75rem; color: #6b7280; text-transform: uppercase;'>{$key}</div>
|
||||
<div style='font-weight: 600; color: {$color}; font-size: 0.875rem;'>{$status}</div>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
return new HtmlString($html);
|
||||
}),
|
||||
KeyValue::make('infrastructure_status')
|
||||
->label('Trạng thái hạ tầng')
|
||||
->label('Chỉnh sửa hạ tầng chi tiết')
|
||||
->addActionLabel('Thêm hạng mục hạ tầng')
|
||||
->keyLabel('Hạng mục')
|
||||
->valueLabel('Trạng thái hiện tại')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
]),
|
||||
Tabs\Tab::make('Tiến độ thanh toán')
|
||||
->icon('heroicon-o-calendar-days')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Repeater::make('payment_schedule_preview')
|
||||
->label('Lịch trình dự kiến / Thực tế')
|
||||
->relationship(function ($record) {
|
||||
// Nếu sản phẩm đã có hợp đồng, lấy lịch trình từ hợp đồng đầu tiên
|
||||
if ($record && $record->contracts()->exists()) {
|
||||
return $record->contracts()->first()->scheduleItems();
|
||||
}
|
||||
// Nếu chưa có, chúng ta sẽ hiển thị mẫu từ Project (phần này cần logic state giả lập hoặc view)
|
||||
return null;
|
||||
})
|
||||
->schema([
|
||||
TextInput::make('installment_no')->label('Đợt')->disabled(),
|
||||
TextInput::make('type')->label('Loại')->disabled(),
|
||||
@@ -83,7 +121,7 @@ class ProductForm
|
||||
->addable(false)
|
||||
->deletable(false)
|
||||
->reorderable(false)
|
||||
->placeholder('Sản phẩm chưa có hợp đồng hoặc Dự án chưa gán mẫu thanh toán mặc định.')
|
||||
->dehydrated(false)
|
||||
]),
|
||||
])->columnSpanFull()
|
||||
]);
|
||||
|
||||
@@ -4,14 +4,12 @@ namespace App\Filament\Resources\Projects;
|
||||
|
||||
use App\Filament\Resources\Projects\Pages;
|
||||
use App\Models\Project;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use App\Enums\NavigationGroup;
|
||||
use App\Filament\Resources\Projects\Schemas\ProjectForm;
|
||||
|
||||
class ProjectResource extends Resource
|
||||
{
|
||||
@@ -25,28 +23,7 @@ class ProjectResource extends Resource
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Thông tin Dự án')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('code')->label('Mã Dự án')->required()->unique(ignoreRecord: true),
|
||||
TextInput::make('name')->label('Tên Dự án')->required(),
|
||||
Select::make('payment_template_id')
|
||||
->label('Mẫu thanh toán mặc định')
|
||||
->relationship('paymentTemplate', 'name')
|
||||
->placeholder('Chọn mẫu thanh toán cho toàn dự án')
|
||||
->columnSpanFull(),
|
||||
Select::make('type')
|
||||
->label('Loại hình')
|
||||
->options([
|
||||
'Khu đô thị' => 'Khu đô thị',
|
||||
'Chung cư' => 'Chung cư',
|
||||
'Đất nền phân lô' => 'Đất nền phân lô',
|
||||
])->required(),
|
||||
TextInput::make('address')->label('Địa chỉ')->columnSpanFull(),
|
||||
])
|
||||
]);
|
||||
return ProjectForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Filament\Resources\Projects\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ProjectForm
|
||||
@@ -10,7 +13,25 @@ class ProjectForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
//
|
||||
Section::make('Thông tin Dự án')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('code')->label('Mã Dự án')->required()->unique(ignoreRecord: true),
|
||||
TextInput::make('name')->label('Tên Dự án')->required(),
|
||||
Select::make('payment_template_id')
|
||||
->label('Mẫu thanh toán mặc định')
|
||||
->relationship('paymentTemplate', 'name')
|
||||
->placeholder('Chọn mẫu thanh toán cho toàn dự án')
|
||||
->columnSpanFull(),
|
||||
Select::make('type')
|
||||
->label('Loại hình')
|
||||
->options([
|
||||
'Khu đô thị' => 'Khu đô thị',
|
||||
'Chung cư' => 'Chung cư',
|
||||
'Đất nền phân lô' => 'Đất nền phân lô',
|
||||
])->required(),
|
||||
TextInput::make('address')->label('Địa chỉ')->columnSpanFull(),
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,17 @@ class Contract extends Model
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'discount_details' => 'array',
|
||||
'total_value' => 'decimal:2',
|
||||
'land_value' => 'decimal:2',
|
||||
'foundation_value' => 'decimal:2',
|
||||
'total_value_with_foundation' => 'decimal:2',
|
||||
'paid_amount' => 'decimal:2',
|
||||
'remaining_amount' => 'decimal:2',
|
||||
'excess_amount' => 'decimal:2',
|
||||
'signing_date' => 'date',
|
||||
'sale_date' => 'date',
|
||||
'hql_confirmation_date' => 'date',
|
||||
];
|
||||
|
||||
public function product()
|
||||
@@ -45,18 +51,15 @@ class Contract extends Model
|
||||
return $this->hasOne(PaymentSchedule::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy trực tiếp các đợt thanh toán của hợp đồng này
|
||||
*/
|
||||
public function scheduleItems(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
PaymentScheduleItem::class,
|
||||
PaymentSchedule::class,
|
||||
'contract_id', // Khóa ngoại trên bảng PaymentSchedule
|
||||
'schedule_id', // Khóa ngoại trên bảng PaymentScheduleItem
|
||||
'id', // Khóa chính trên bảng Contract
|
||||
'id' // Khóa chính trên bảng PaymentSchedule
|
||||
'contract_id',
|
||||
'schedule_id',
|
||||
'id',
|
||||
'id'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,17 +75,22 @@ class Contract extends Model
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($contract) {
|
||||
// Tự động lấy giá trị từ sản phẩm nếu chưa có
|
||||
if (empty($contract->total_value) && !empty($contract->product_id)) {
|
||||
static::saving(function ($contract) {
|
||||
// Bảo vệ tính toán tài chính: total_value luôn bằng land_value + foundation_value
|
||||
$landValue = (float) ($contract->land_value ?? 0);
|
||||
$foundationValue = (float) ($contract->foundation_value ?? 0);
|
||||
|
||||
if ($landValue > 0 || $foundationValue > 0) {
|
||||
$contract->total_value = $landValue + $foundationValue;
|
||||
} elseif ($contract->exists === false && empty($contract->total_value) && !empty($contract->product_id)) {
|
||||
// Fallback khi tạo mới và chưa có giá trị tài chính chi tiết
|
||||
$product = Product::find($contract->product_id);
|
||||
if ($product) {
|
||||
$contract->total_value = $product->total_price;
|
||||
}
|
||||
}
|
||||
|
||||
// Tính toán số tiền còn lại
|
||||
$contract->remaining_amount = ($contract->total_value ?? 0) - ($contract->paid_amount ?? 0);
|
||||
$contract->remaining_amount = (float) ($contract->total_value ?? 0) - (float) ($contract->paid_amount ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Customer extends Model
|
||||
{
|
||||
@@ -12,12 +14,28 @@ class Customer extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
// Ép kiểu để Laravel tự động dịch JSON thành Mảng khi hiển thị lên Form
|
||||
protected $casts = [
|
||||
'address' => 'array',
|
||||
'secondary_phones' => 'array',
|
||||
'dob' => 'date',
|
||||
'id_issue_date' => 'date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lấy các công ty mà khách hàng này đại diện
|
||||
*/
|
||||
public function representedCompanies(): HasMany
|
||||
{
|
||||
return $this->hasMany(Customer::class, 'representative_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy người đại diện của công ty này
|
||||
*/
|
||||
public function representative(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'representative_id');
|
||||
}
|
||||
|
||||
public function contracts()
|
||||
{
|
||||
return $this->belongsToMany(Contract::class, 'contract_customers')
|
||||
|
||||
120
app/Observers/PaymentObserver.php
Normal file
120
app/Observers/PaymentObserver.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Payment;
|
||||
use App\Models\Contract;
|
||||
use App\Models\PaymentScheduleItem;
|
||||
|
||||
class PaymentObserver
|
||||
{
|
||||
private static bool $handlingSurplus = false;
|
||||
|
||||
/**
|
||||
* Tính toán lại tài chính hợp đồng sau mỗi thay đổi payment.
|
||||
*/
|
||||
private function recalculateContract(Contract $contract): void
|
||||
{
|
||||
$totalPaid = (float) $contract->payments()->sum('amount');
|
||||
$contractValue = (float) $contract->total_value;
|
||||
|
||||
$contract->paid_amount = $totalPaid;
|
||||
|
||||
if ($totalPaid > $contractValue) {
|
||||
$contract->remaining_amount = 0;
|
||||
$contract->excess_amount = $totalPaid - $contractValue;
|
||||
} else {
|
||||
$contract->remaining_amount = $contractValue - $totalPaid;
|
||||
$contract->excess_amount = 0;
|
||||
}
|
||||
|
||||
$contract->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tự động khấu trừ tiền dư vào đợt thanh toán tiếp theo.
|
||||
*/
|
||||
private function applySurplusToNextInstallment(Contract $contract): void
|
||||
{
|
||||
if (self::$handlingSurplus) {
|
||||
return;
|
||||
}
|
||||
|
||||
$excess = (float) $contract->excess_amount;
|
||||
if ($excess <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tìm đợt tiếp theo chưa thanh toán đủ (hoặc chưa có payment nào)
|
||||
$nextItem = PaymentScheduleItem::query()
|
||||
->whereHas('schedule', fn ($q) => $q->where('contract_id', $contract->id))
|
||||
->whereNotNull('amount')
|
||||
->orderBy('installment_no')
|
||||
->get()
|
||||
->first(function ($item) use ($contract) {
|
||||
$paidForItem = (float) $contract->payments()
|
||||
->where('schedule_item_id', $item->id)
|
||||
->sum('amount');
|
||||
return $paidForItem < (float) $item->amount;
|
||||
});
|
||||
|
||||
if (! $nextItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paidForItem = (float) $contract->payments()
|
||||
->where('schedule_item_id', $nextItem->id)
|
||||
->sum('amount');
|
||||
$remainingForItem = (float) $nextItem->amount - $paidForItem;
|
||||
|
||||
if ($remainingForItem <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$applyAmount = min($excess, $remainingForItem);
|
||||
|
||||
self::$handlingSurplus = true;
|
||||
|
||||
Payment::create([
|
||||
'contract_id' => $contract->id,
|
||||
'schedule_item_id' => $nextItem->id,
|
||||
'amount' => $applyAmount,
|
||||
'paid_date' => now(),
|
||||
'method' => 'Tự động khấu trừ',
|
||||
'receipt_number' => 'AUTO-SURPLUS-' . now()->format('YmdHis'),
|
||||
'metadata' => ['auto_surplus' => true, 'source' => 'excess_amount'],
|
||||
]);
|
||||
|
||||
self::$handlingSurplus = false;
|
||||
}
|
||||
|
||||
public function created(Payment $payment): void
|
||||
{
|
||||
if ($payment->contract) {
|
||||
$this->recalculateContract($payment->contract);
|
||||
$this->applySurplusToNextInstallment($payment->contract);
|
||||
}
|
||||
}
|
||||
|
||||
public function updated(Payment $payment): void
|
||||
{
|
||||
if ($payment->contract) {
|
||||
$this->recalculateContract($payment->contract);
|
||||
$this->applySurplusToNextInstallment($payment->contract);
|
||||
}
|
||||
|
||||
if ($payment->wasChanged('contract_id') && $payment->getOriginal('contract_id')) {
|
||||
$oldContract = Contract::find($payment->getOriginal('contract_id'));
|
||||
if ($oldContract) {
|
||||
$this->recalculateContract($oldContract);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function deleted(Payment $payment): void
|
||||
{
|
||||
if ($payment->contract) {
|
||||
$this->recalculateContract($payment->contract);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Payment;
|
||||
use App\Observers\PaymentObserver;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +21,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Payment::observe(PaymentObserver::class);
|
||||
}
|
||||
}
|
||||
|
||||
69
app/Services/ContractScheduleService.php
Normal file
69
app/Services/ContractScheduleService.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Models\PaymentSchedule;
|
||||
use App\Models\PaymentScheduleItem;
|
||||
use App\Models\PaymentTemplate;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ContractScheduleService
|
||||
{
|
||||
/**
|
||||
* Tạo lịch thanh toán cho hợp đồng dựa trên mẫu.
|
||||
* Nếu đã tồn tại lịch cũ, sẽ xóa và tạo lại.
|
||||
*/
|
||||
public static function generateFromTemplate(Contract $contract, ?PaymentTemplate $template = null): PaymentSchedule
|
||||
{
|
||||
if (! $template) {
|
||||
// Ưu tiên template của dự án
|
||||
$template = $contract->product?->project?->paymentTemplate;
|
||||
}
|
||||
|
||||
if (! $template) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy mẫu thanh toán cho hợp đồng này.');
|
||||
}
|
||||
|
||||
// Xóa lịch cũ nếu có
|
||||
if ($contract->paymentSchedule) {
|
||||
$contract->paymentSchedule->items()->delete();
|
||||
$contract->paymentSchedule->delete();
|
||||
}
|
||||
|
||||
$schedule = PaymentSchedule::create([
|
||||
'contract_id' => $contract->id,
|
||||
'template_id' => $template->id,
|
||||
]);
|
||||
|
||||
$items = $template->items()->orderBy('installment_no')->get();
|
||||
$lastDueDate = Carbon::parse($contract->signing_date);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$dueDate = null;
|
||||
|
||||
if ($item->days_after_signing !== null) {
|
||||
$dueDate = Carbon::parse($contract->signing_date)->addDays($item->days_after_signing);
|
||||
} elseif ($item->days_after_previous !== null) {
|
||||
$dueDate = $lastDueDate->copy()->addDays($item->days_after_previous);
|
||||
} elseif ($item->due_date !== null) {
|
||||
$dueDate = $item->due_date;
|
||||
}
|
||||
|
||||
PaymentScheduleItem::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'installment_no' => $item->installment_no,
|
||||
'type' => $item->type,
|
||||
'percentage' => $item->percentage,
|
||||
'amount' => $contract->total_value * ($item->percentage / 100),
|
||||
'due_date' => $dueDate,
|
||||
]);
|
||||
|
||||
if ($dueDate) {
|
||||
$lastDueDate = $dueDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $schedule;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,26 @@
|
||||
"php": "^8.3",
|
||||
"filament/filament": "^5.5",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
"laravel/tinker": "^3.0",
|
||||
"phpoffice/phpspreadsheet": "^5.7",
|
||||
"symfony/clock": "^7.2",
|
||||
"symfony/console": "^7.2",
|
||||
"symfony/css-selector": "^7.2",
|
||||
"symfony/error-handler": "^7.2",
|
||||
"symfony/event-dispatcher": "^7.2",
|
||||
"symfony/finder": "^7.2",
|
||||
"symfony/html-sanitizer": "^7.2",
|
||||
"symfony/http-foundation": "^7.2",
|
||||
"symfony/http-kernel": "^7.2",
|
||||
"symfony/mailer": "^7.2",
|
||||
"symfony/mime": "^7.2",
|
||||
"symfony/process": "^7.2",
|
||||
"symfony/routing": "^7.2",
|
||||
"symfony/string": "^7.2",
|
||||
"symfony/translation": "^7.2",
|
||||
"symfony/uid": "^7.2",
|
||||
"symfony/var-dumper": "^7.2",
|
||||
"symfony/yaml": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
@@ -20,6 +39,7 @@
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.6",
|
||||
"pestphp/pest-plugin-laravel": "^4.1",
|
||||
"pestphp/pest-plugin-livewire": "^4.1",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -64,7 +84,6 @@
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
|
||||
1234
composer.lock
generated
1234
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
'default' => env('DB_CONNECTION', 'pgsql'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -11,17 +11,20 @@ class CustomerFactory extends Factory
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$street = $this->faker->streetAddress();
|
||||
$ward = 'Phường ' . $this->faker->numberBetween(1, 15);
|
||||
$district = 'Quận ' . $this->faker->numberBetween(1, 12);
|
||||
$city = $this->faker->city();
|
||||
$fullAddress = "{$street}, {$ward}, {$district}, {$city}";
|
||||
|
||||
return [
|
||||
'full_name' => $this->faker->name(),
|
||||
'cmnd_cccd' => $this->faker->unique()->numerify('0##########'),
|
||||
'phone' => $this->faker->phoneNumber(),
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'address' => [
|
||||
'street' => $this->faker->streetAddress(),
|
||||
'ward' => 'Phường ' . $this->faker->numberBetween(1, 15),
|
||||
'district' => 'Quận ' . $this->faker->numberBetween(1, 12),
|
||||
'city' => $this->faker->city(),
|
||||
],
|
||||
'type' => 'INDIVIDUAL',
|
||||
'permanent_address' => $fullAddress,
|
||||
'contact_address' => $fullAddress,
|
||||
'dob' => $this->faker->date(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
// Loại khách hàng
|
||||
$table->string('type')->default('INDIVIDUAL'); // INDIVIDUAL, COMPANY
|
||||
|
||||
// Liên kết người đại diện (Self-referencing)
|
||||
$table->uuid('representative_id')->nullable();
|
||||
$table->foreign('representative_id')->references('id')->on('customers')->onDelete('set null');
|
||||
|
||||
// Thông tin định danh mở rộng
|
||||
$table->string('tax_code')->nullable();
|
||||
$table->date('id_issue_date')->nullable();
|
||||
$table->string('id_issue_place')->nullable();
|
||||
$table->string('title')->nullable(); // Ông/Bà
|
||||
|
||||
// Địa chỉ lưu cứng
|
||||
$table->text('permanent_address')->nullable(); // Địa chỉ thường trú
|
||||
$table->text('contact_address')->nullable(); // Địa chỉ liên hệ
|
||||
|
||||
// Số điện thoại phụ
|
||||
$table->jsonb('secondary_phones')->nullable();
|
||||
|
||||
// Xóa cột address cũ nếu tồn tại (để tránh nhầm lẫn)
|
||||
if (Schema::hasColumn('customers', 'address')) {
|
||||
$table->dropColumn('address');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
$table->dropColumn(['type', 'representative_id', 'tax_code', 'id_issue_date', 'id_issue_place', 'title', 'permanent_address', 'contact_address', 'secondary_phones']);
|
||||
$table->jsonb('address')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::table('contracts', function (Blueprint $table) {
|
||||
// Tài chính chi tiết tại thời điểm ký
|
||||
$table->decimal('land_value', 15, 2)->default(0); // Giá trị QSDĐ
|
||||
$table->decimal('foundation_value', 15, 2)->default(0); // Giá trị móng
|
||||
$table->decimal('total_value_with_foundation', 15, 2)->default(0); // Tổng giá trị HĐMB
|
||||
|
||||
// Dữ liệu chiết khấu động
|
||||
$table->jsonb('discount_details')->nullable();
|
||||
|
||||
// Các thông tin bổ sung từ Excel
|
||||
$table->string('brokerage_name')->nullable(); // Môi giới
|
||||
$table->date('sale_date')->nullable(); // Ngày bán
|
||||
$table->date('hql_confirmation_date')->nullable(); // Ngày HQL XN
|
||||
|
||||
// Trạng thái thu hồi/lưu trữ
|
||||
$table->integer('stored_contract_count')->default(0); // HĐ lưu
|
||||
$table->text('filing_note')->nullable(); // Lưu hồ sơ
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
Schema::table('contracts', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'land_value', 'foundation_value', 'total_value_with_foundation',
|
||||
'discount_details', 'brokerage_name', 'sale_date',
|
||||
'hql_confirmation_date', 'stored_contract_count', 'filing_note'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
BIN
hopdong.xlsx
Normal file
BIN
hopdong.xlsx
Normal file
Binary file not shown.
BIN
khachhang.xlsx
Normal file
BIN
khachhang.xlsx
Normal file
Binary file not shown.
@@ -23,8 +23,12 @@
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="DB_CONNECTION" value="pgsql"/>
|
||||
<env name="DB_HOST" value="127.0.0.1"/>
|
||||
<env name="DB_PORT" value="5432"/>
|
||||
<env name="DB_DATABASE" value="laravel_testing"/>
|
||||
<env name="DB_USERNAME" value="sail"/>
|
||||
<env name="DB_PASSWORD" value="password"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
|
||||
BIN
sanpham.xlsx
Normal file
BIN
sanpham.xlsx
Normal file
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Filament\Resources\Contracts\ContractResource;
|
||||
use App\Filament\Resources\Contracts\Pages;
|
||||
use App\Models\Contract;
|
||||
use App\Models\Customer;
|
||||
use App\Models\PaymentSchedule;
|
||||
@@ -12,6 +13,7 @@ use App\Models\Product;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use function Pest\Livewire\livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
@@ -55,16 +57,14 @@ it('can create a contract and automatically generate a payment schedule from tem
|
||||
// 3. Thực hiện tạo Hợp đồng qua Livewire Page
|
||||
$signingDate = Carbon::now();
|
||||
|
||||
\Livewire\Livewire::test(\App\Filament\Resources\Contracts\Pages\CreateContract::class)
|
||||
->fillForm([
|
||||
'product_id' => $product->id,
|
||||
'contract_number' => 'HD-TEST-UUID',
|
||||
'contract_type' => 'HĐMB',
|
||||
'signing_date' => $signingDate->format('Y-m-d'),
|
||||
'total_value' => 1000000000,
|
||||
'payment_template_id' => $template->id, // Chọn mẫu thanh toán
|
||||
'customers' => [$customer->id], // Đồng sở hữu
|
||||
])
|
||||
livewire(Pages\CreateContract::class)
|
||||
->set('data.product_id', $product->id)
|
||||
->set('data.contract_number', 'HD-TEST-UUID')
|
||||
->set('data.contract_type', 'HĐMB')
|
||||
->set('data.signing_date', $signingDate->format('Y-m-d'))
|
||||
->set('data.total_value', 1000000000)
|
||||
->set('data.payment_template_id', $template->id)
|
||||
->set('data.customers', [$customer->id])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
@@ -73,34 +73,23 @@ it('can create a contract and automatically generate a payment schedule from tem
|
||||
// A. Hợp đồng phải tồn tại
|
||||
$contract = Contract::where('contract_number', 'HD-TEST-UUID')->first();
|
||||
expect($contract)->not->toBeNull();
|
||||
expect($contract->id)->toBeUuid(); // Kiểm tra khóa chính là UUID
|
||||
expect($contract->id)->toBeUuid();
|
||||
|
||||
// B. Kiểm tra bảng trung gian contract_customers (Phải có UUID)
|
||||
// B. Kiểm tra bảng trung gian contract_customers
|
||||
$this->assertDatabaseHas('contract_customers', [
|
||||
'contract_id' => $contract->id,
|
||||
'customer_id' => $customer->id,
|
||||
]);
|
||||
|
||||
$pivot = \DB::table('contract_customers')->where('contract_id', $contract->id)->first();
|
||||
expect($pivot->id)->toBeUuid(); // KIỂM TRA QUAN TRỌNG: ID bảng trung gian phải là UUID
|
||||
|
||||
// C. Kiểm tra Lịch trình thanh toán (Payment Schedule)
|
||||
// C. Kiểm tra Lịch trình thanh toán
|
||||
$schedule = PaymentSchedule::where('contract_id', $contract->id)->first();
|
||||
expect($schedule)->not->toBeNull();
|
||||
expect($schedule->template_id)->toBe($template->id);
|
||||
|
||||
// D. Kiểm tra các đợt thanh toán con (Items)
|
||||
// D. Kiểm tra các đợt thanh toán con
|
||||
$scheduleItems = PaymentScheduleItem::where('schedule_id', $schedule->id)
|
||||
->orderBy('installment_no')
|
||||
->get();
|
||||
|
||||
expect($scheduleItems)->toHaveCount(2);
|
||||
|
||||
// Kiểm tra đợt 1 (30% = 300 triệu)
|
||||
expect((float)$scheduleItems[0]->amount)->toBe(300000000.0);
|
||||
expect($scheduleItems[0]->due_date->format('Y-m-d'))->toBe($signingDate->format('Y-m-d'));
|
||||
|
||||
// Kiểm tra đợt 2 (30% = 300 triệu, sau 30 ngày)
|
||||
expect((float)$scheduleItems[1]->amount)->toBe(300000000.0);
|
||||
expect($scheduleItems[1]->due_date->format('Y-m-d'))->toBe($signingDate->copy()->addDays(30)->format('Y-m-d'));
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\Products\ProductResource;
|
||||
use App\Filament\Resources\Products\Pages;
|
||||
use App\Models\Product;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\Testing\Livewire;
|
||||
use Filament\Pages\Actions\DeleteAction as ActionsDeleteAction;
|
||||
use function Pest\Livewire\livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
// Tạo user và đăng nhập để qua được middleware auth của Filament
|
||||
$this->actingAs(User::factory()->create());
|
||||
});
|
||||
|
||||
@@ -22,25 +19,24 @@ it('can render list products page', function () {
|
||||
it('can render create product page and has required fields', function () {
|
||||
$this->get(ProductResource::getUrl('create'))->assertStatus(200);
|
||||
|
||||
livewire(ProductResource\Pages\CreateProduct::class)
|
||||
livewire(Pages\CreateProduct::class)
|
||||
->assertFormExists()
|
||||
->assertFormFieldExists('code')
|
||||
->assertFormFieldExists('area');
|
||||
->assertFormFieldExists('code');
|
||||
});
|
||||
|
||||
it('can create a new product via livewire form', function () {
|
||||
$project = Project::factory()->create();
|
||||
|
||||
livewire(ProductResource\Pages\CreateProduct::class)
|
||||
->fillForm([
|
||||
'project_id' => $project->id,
|
||||
'product_type' => 'LAND',
|
||||
'code' => 'TEST-001',
|
||||
'area' => 100,
|
||||
'price_per_unit' => 50000000,
|
||||
'total_price' => 5000000000,
|
||||
'status' => 'Đang mở bán',
|
||||
])
|
||||
livewire(Pages\CreateProduct::class)
|
||||
->set('data.project_id', $project->id)
|
||||
->set('data.product_type', 'LAND')
|
||||
->set('data.code', 'TEST-001')
|
||||
->set('data.area', 100)
|
||||
->set('data.price_per_unit', 50000000)
|
||||
->set('data.total_price', 5000000000)
|
||||
->set('data.qsdd_value', 0)
|
||||
->set('data.foundation_temp_value', 0)
|
||||
->set('data.status', 'Đang mở bán')
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
@@ -55,12 +51,10 @@ it('can render edit page and update product data', function () {
|
||||
|
||||
$this->get(ProductResource::getUrl('edit', ['record' => $product]))->assertStatus(200);
|
||||
|
||||
livewire(ProductResource\Pages\EditProduct::class, [
|
||||
livewire(Pages\EditProduct::class, [
|
||||
'record' => $product->getRouteKey(),
|
||||
])
|
||||
->fillForm([
|
||||
'code' => 'NEW-CODE',
|
||||
])
|
||||
->set('data.code', 'NEW-CODE')
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
@@ -70,7 +64,7 @@ it('can render edit page and update product data', function () {
|
||||
it('can delete a product from edit page', function () {
|
||||
$product = Product::factory()->create();
|
||||
|
||||
livewire(ProductResource\Pages\EditProduct::class, [
|
||||
livewire(Pages\EditProduct::class, [
|
||||
'record' => $product->getRouteKey(),
|
||||
])
|
||||
->callAction(DeleteAction::class);
|
||||
|
||||
Reference in New Issue
Block a user