Compare commits

...

10 Commits

67 changed files with 2942 additions and 168 deletions

107
AGENTS.md
View File

@@ -1,7 +1,7 @@
# HQLAND - TRẠNG THÁI CODEBASE & LỘ TRÌNH PHÁT TRIỂN # 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. > 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 > **Cập nhật:** 29/04/2026
> **Dự án:** HQLand - Hệ thống quản lý Bất động sản > **Dự án:** HQLand - Hệ thống quản lý Bất động sản
> **Stack:** Laravel 13 + Filament v5.5 (Schemas Architecture) + PostgreSQL + UUID > **Stack:** Laravel 13 + Filament v5.5 (Schemas Architecture) + PostgreSQL + UUID
@@ -51,6 +51,13 @@
4. Mọi trường JSONB trong Model phải khai báo trong `$casts = ['field' => 'array']`. 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. 5. Naming database: **snake_case** cho mọi bảng và cột.
### ⚠️ Quy tắc Layout Filament v5.5 (BÀI HỌC QUAN TRỌNG)
- **Section muốn full width** phải thêm `->columnSpanFull()` ngay sau `Section::make()`
- **Schema mặc định** có thể tự động chia cột nếu không chỉ định `columnSpanFull`
- **Grid::make(3)** chỉ dùng *bên trong* Section để chia field thành cột, KHÔNG dùng để bọc nhiều Section
- **Layout đúng:** Section xếp dọc (mỗi Section `->columnSpanFull()`), bên trong Section dùng Grid chia field
- **RichEditor tăng chiều cao:** `->extraInputAttributes(['style' => 'min-height: 500px;'])`
--- ---
## 3. CẤU TRÚC MODULE HIỆN TẠI ## 3. CẤU TRÚC MODULE HIỆN TẠI
@@ -121,6 +128,7 @@
**Quan hệ:** **Quan hệ:**
- `product()`, `customers()` (belongsToMany qua contract_customers) - `product()`, `customers()` (belongsToMany qua contract_customers)
- `paymentTemplate()` (belongsTo PaymentTemplate) - `paymentTemplate()` (belongsTo PaymentTemplate)
- `salesPhase()` (belongsTo SalesPhase)
- `appendices()`, `paymentSchedule()`, `scheduleItems()` (HasManyThrough) - `appendices()`, `paymentSchedule()`, `scheduleItems()` (HasManyThrough)
- `payments()`, `paymentFines()` - `payments()`, `paymentFines()`
@@ -207,6 +215,38 @@
--- ---
### 3.6. Sales Phases (Đợt mở bán)
**Models:** `SalesPhase`, `SalesPhaseProduct` (pivot)
**SalesPhase:**
- `project_id`, `name`, `code`, `status` (Chuẩn bị | Đang mở bán | Tạm dừng | Đã đóng)
- `start_date`, `end_date`, `description`
- `payment_template_id` - Mẫu lịch thanh toán mặc định cho đợt
- `discount_policy` (JSONB) - Chính sách chiết khấu mặc định
**SalesPhaseProduct (pivot):**
- `sales_phase_id`, `product_id`
- `sale_price`, `land_value`, `foundation_value` - Giá bán riêng của đợt
- `discount_details` (JSONB) - Chiết khấu riêng cho từng sản phẩm trong đợt
- `status` (Còn hàng | Đã giữ | Đã bán | Khóa)
**Quan hệ:**
- `SalesPhase` belongsTo `Project`, belongsTo `PaymentTemplate`
- `SalesPhase` belongsToMany `Product` qua `sales_phase_products`
- `Product` belongsToMany `SalesPhase`
- `Project` hasMany `SalesPhase`
- `Contract` belongsTo `SalesPhase` (nullable)
**Logic tích hợp:**
- `ContractForm`: Khi chọn `sales_phase_id` + `product_id`, tự động populate giá trị tài chính từ `SalesPhaseProduct` pivot
- `CreateContract`: Nếu HĐ có `sales_phase_id` và không có `payment_template_id` trực tiếp, tự động lấy template từ `salesPhase->paymentTemplate`
- `SalesPhaseForm`: Repeater `phaseProducts` cho phép thêm sản phẩm với giá và chiết khấu riêng
**Filament Resources:**
- `SalesPhaseResource``SalesPhaseForm` + `SalesPhasesTable`
---
## 4. CÁC COMMAND IMPORT DỮ LIỆU ## 4. CÁC COMMAND IMPORT DỮ LIỆU
### `import:products-excel {file=sanpham.xlsx}` ### `import:products-excel {file=sanpham.xlsx}`
@@ -262,10 +302,16 @@
- [x] **Discount Engine:** Tính toán tự động chiết khấu + hiển thị `final_value` trong ContractForm - [x] **Discount Engine:** Tính toán tự động chiết khấu + hiển thị `final_value` trong ContractForm
- [x] **Calculation Pipeline:** Kiến trúc tính toán tường minh (Step-by-step) với làm tròn tại mỗi bước - [x] **Calculation Pipeline:** Kiến trúc tính toán tường minh (Step-by-step) với làm tròn tại mỗi bước
- [x] **Form Templates:** Mail Merge Engine cho phiếu tính giá, HĐ, phụ lục - Admin tự tạo template - [x] **Form Templates:** Mail Merge Engine cho phiếu tính giá, HĐ, phụ lục - Admin tự tạo template
- [x] **Sales Phase Module:** Quản lý đợt mở bán, giá bán riêng theo đợt, tích hợp vào ContractForm
- [x] **ProjectReport Page:** Báo cáo thống kê bán hàng, thanh toán theo từng dự án
- [x] **ExportDebtReport Command:** Xuất Excel báo cáo công nợ khách hàng (2 sheet: Tổng hợp + Chi tiết đợt TT)
- [x] **Notification System:** `PaymentDueNotification` + `SendPaymentDueNotifications` command + `RecentNotifications` widget
- [x] **Soft Delete:** Contract, Payment, Customer + Restore/ForceDelete UI trong Filament Tables
- [x] **Payment.collected_by:** Ghi nhận ngườ thu tiền trong PaymentForm + PaymentsTable
### 5.2. Đang dở / Cần tiếp tục ### 5.2. Đang dở / Cần tiếp tục
- [x] **Dashboard thống kê:** Đã tạo `ContractStatsOverview` + `UpcomingPaymentsTable` - [x] **Dashboard thống kê:** Đã tạo `ContractStatsOverview` + `UpcomingPaymentsTable`
- [ ] **Notification:** Cảnh báo đợt thanh toán sắp đến hạn (chưa có hệ thống notification) - [x] **Notification:** Đã có hệ thống cảnh báo đợt thanh toán sắp đến hạn (database notifications)
### 5.3. Vấn đề kỹ thuật ĐÃ XỬ LÝ ### 5.3. Vấn đề kỹ thuật ĐÃ XỬ LÝ
- [x] ContractTable đã thêm cột `paid_amount` / `remaining_amount`, chuyển sang dùng `ContractsTable` Schemas - [x] ContractTable đã thêm cột `paid_amount` / `remaining_amount`, chuyển sang dùng `ContractsTable` Schemas
@@ -285,16 +331,29 @@
4. **Command generate schedule hàng loạt:** `php artisan contracts:generate-schedules` cho 139 hợp đồng đã import 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) ### 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 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 6. **Appendix Resource:** Quản lý phụ lục hợp đồng
7. **Settlement Resource:** Quản lý thanh lý 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 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) ### 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 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 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 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 12. **Notification:** Cảnh báo đợt thanh toán sắp đến hạn
### Giai đoạn 4: An toàn & Audit (Đang làm)
13. **Soft Delete:** Contract, Payment, Customer + Restore/ForceDelete UI ✅
14. **Payment.collected_by:** Ghi nhận ngườ thu tiền ✅
### Giai đoạn 5: Phân quyền (Thiết kế xong, chờ triển khai)
15. **Permission System (Hướng B - Tự viết, không Spatie):**
- `role_templates`: Mẫu nhóm với permissions JSONB
- `users`: role_template_id + extra_permissions + excluded_permissions
- Session cache effective permissions (tính 1 lần/login)
- `php artisan permissions:sync` thủ công khi thêm module
- Action mới mặc định TẮT, admin bật thủ công
- Xem chi tiết: `WORKFLOW.md` Phần VIII
--- ---
@@ -327,42 +386,62 @@ DB_HOST=127.0.0.1 php artisan migrate
- `database/migrations/2026_04_24_083000_add_payment_template_id_to_contracts.php` - `database/migrations/2026_04_24_083000_add_payment_template_id_to_contracts.php`
- `database/migrations/2026_04_28_013900_add_calculation_log_to_contracts.php` - `database/migrations/2026_04_28_013900_add_calculation_log_to_contracts.php`
- `database/migrations/2026_04_28_020000_create_form_templates_tables.php` - `database/migrations/2026_04_28_020000_create_form_templates_tables.php`
- `database/migrations/2026_04_28_030000_create_sales_phases_tables.php`
- `database/migrations/2026_04_28_031000_add_sales_phase_id_to_contracts.php`
- `database/migrations/2026_04_29_000000_create_notifications_table.php`
### Services mới ### Services mới
- `app/Services/DiscountEngine.php` - Tính toán chiết khấu - `app/Services/DiscountEngine.php` - Tính toán chiết khấu
- `app/Services/Calculation/` - Calculation Pipeline (RoundingRule, CalculationStep, CalculationResult, CalculationPipeline, PriceCalculationService) - `app/Services/Calculation/` - Calculation Pipeline (RoundingRule, CalculationStep, CalculationResult, CalculationPipeline, PriceCalculationService)
- `app/Services/Forms/MailMergeService.php` - Engine xử lý biểu mẫu in ấn - `app/Services/Forms/MailMergeService.php` - Engine xử lý biểu mẫu in ấn
- `app/Console/Commands/GenerateContractSchedules.php` - Command tạo lịch hàng loạt - `app/Console/Commands/GenerateContractSchedules.php` - Command tạo lịch hàng loạt
- `app/Console/Commands/ExportDebtReport.php` - Command xuất Excel báo cáo công nợ
- `app/Console/Commands/SendPaymentDueNotifications.php` - Command gửi cảnh báo đợt TT sắp đến hạn
- `app/Notifications/PaymentDueNotification.php` - Database notification cho đợt thanh toán
### Filament Resources mới ### Filament Resources mới
- `app/Filament/Resources/PaymentFines/` (Resource + Form + Table + Pages) - `app/Filament/Resources/PaymentFines/` (Resource + Form + Table + Pages)
- `app/Filament/Resources/Appendices/` (Resource + Form + Table + Pages) - `app/Filament/Resources/Appendices/` (Resource + Form + Table + Pages)
- `app/Filament/Resources/Settlements/` (Resource + Form + Table + Pages) - `app/Filament/Resources/Settlements/` (Resource + Form + Table + Pages)
- `app/Filament/Resources/FormTemplates/` (Resource + Form + Table + Pages) - `app/Filament/Resources/FormTemplates/` (Resource + Form + Table + Pages)
- `app/Filament/Resources/SalesPhases/` (Resource + Form + Table + Pages)
### Widgets mới ### Widgets mới
- `app/Filament/Widgets/ContractStatsOverview.php` - Dashboard tổng quan tài chính - `app/Filament/Widgets/ContractStatsOverview.php` - Dashboard tổng quan tài chính
- `app/Filament/Widgets/UpcomingPaymentsTable.php` - Danh sách đợt TT sắp đến hạn - `app/Filament/Widgets/UpcomingPaymentsTable.php` - Danh sách đợt TT sắp đến hạn
- `app/Filament/Widgets/RecentNotifications.php` - Danh sách thông báo chưa đọc
### Filament Pages mới
- `app/Filament/Pages/ProjectReport.php` - Báo cáo thống kê theo dự án
### Models mới
- `app/Models/SalesPhase.php` - Đợt mở bán
- `app/Models/SalesPhaseProduct.php` - Pivot model giá bán theo đợt
### Models sửa đổi ### Models sửa đổi
- `app/Models/Contract.php` - Thêm `paymentTemplate()`, accessor `final_value` - `app/Models/Contract.php` - Thêm `paymentTemplate()`, `salesPhase()`, accessor `final_value`
- `app/Models/PaymentScheduleItem.php` - Thêm accessor `paid_amount`, `remaining_amount` - `app/Models/PaymentScheduleItem.php` - Thêm accessor `paid_amount`, `remaining_amount`
- `app/Models/Product.php` - Thêm `salesPhases()`, `activeSalesPhase()`
- `app/Models/Project.php` - Thêm `salesPhases()`
- `app/Models/User.php` - Thêm `FilamentUser` interface để user có quyền truy cập panel - `app/Models/User.php` - Thêm `FilamentUser` interface để user có quyền truy cập panel
### Forms/Tables sửa đổi ### Forms/Tables sửa đổi
- `app/Filament/Resources/Contracts/ContractResource.php` - Fix action `EditAction` namespace (`Filament\Actions\EditAction`) - `app/Filament/Resources/Contracts/ContractResource.php` - Fix action `EditAction` namespace (`Filament\Actions\EditAction`)
- `app/Filament/Resources/Contracts/Schemas/ContractForm.php` - Fix `payment_template_id`, thêm `final_value_display` - `app/Filament/Resources/Contracts/Schemas/ContractForm.php` - Fix `payment_template_id`, thêm `final_value_display`, thêm `sales_phase_id` với auto-populate giá từ SalesPhaseProduct
- `app/Filament/Resources/Contracts/Pages/CreateContract.php` - Refactor dùng `$contract->payment_template_id` - `app/Filament/Resources/Contracts/Pages/CreateContract.php` - Refactor dùng `$contract->payment_template_id`, fallback lấy từ `salesPhase->paymentTemplate`
- `app/Filament/Resources/Payments/Schemas/PaymentForm.php` - Thêm validation amount + helper text công nợ - `app/Filament/Resources/Payments/Schemas/PaymentForm.php` - Thêm validation amount + helper text công nợ
- `app/Filament/Resources/Payments/Tables/PaymentsTable.php` - Thêm cột Loại đợt, Đối soát, Còn thiếu - `app/Filament/Resources/Payments/Tables/PaymentsTable.php` - Thêm cột Loại đợt, Đối soát, Còn thiếu
- `app/Filament/Resources/Payments/PaymentResource.php` - Thêm eager load `scheduleItem.payments` - `app/Filament/Resources/Payments/PaymentResource.php` - Thêm eager load `scheduleItem.payments`
### Config/Provider sửa đổi ### Config/Provider sửa đổi
- `app/Providers/Filament/AdminPanelProvider.php` - Đăng ký widgets mới - `app/Providers/Filament/AdminPanelProvider.php` - Đăng ký widgets mới (ContractStatsOverview, UpcomingPaymentsTable, RecentNotifications)
- `phpunit.xml` - Cấu hình PostgreSQL testing (DB_HOST, DB_DATABASE, etc.) - `phpunit.xml` - Cấu hình PostgreSQL testing (DB_HOST, DB_DATABASE, etc.)
- `config/database.php` - Default `pgsql` - `config/database.php` - Default `pgsql`
- `composer.json` - Xóa script tạo SQLite - `composer.json` - Xóa script tạo SQLite
### Views mới
- `resources/views/filament/pages/project-report.blade.php` - View cho ProjectReport Page
--- ---
## 9. FILE HỖ TRỢ CHUYỂN MÁY ## 9. FILE HỖ TRỢ CHUYỂN MÁY

121
ASSESSMENT.md Normal file
View File

@@ -0,0 +1,121 @@
# HQLAND - ĐÁNH GIÁ HIỆN TRẠNG & ĐỀ XUẤT PHÁT TRIỂN
> Đánh giá khách quan sau phiên làm việc 28/04/2026
> Ngườ đánh giá: AI Assistant (Kimi)
> **Lưu ý:** Đây là đánh giá tự phê bình để cải thiện chất lượng hệ thống
---
## I. ĐIỂM MẠNH (Đã hoàn thành tốt)
### 1. Kiến trúc kỹ thuật
- **Schemas Architecture:** Tách biệt Form/Table ra khỏi Resource → code gọn gàng, dễ bảo trì
- **UUID 100%:** Phù hợp với hệ thống phân tán, khó đoán ID
- **PostgreSQL + JSONB:** Tận dụng tốt khả năng lưu trữ linh hoạt của Postgres
- **Testing:** PHPUnit cấu hình đúng PostgreSQL, test tự động chạy được
### 2. Nghiệp vụ tài chính
- **Calculation Pipeline:** Tính toán step-by-step có làm tròn tại mỗi bước → đúng chuẩn kế toán
- **PaymentObserver:** Tự động tính công nợ + khấu trừ dư → giảm sai sót thủ công
- **Lịch thanh toán:** Tạo từ template, hỗ trợ nhiều đợt với ngày đến hạn linh hoạt
- **Form Templates:** Mail Merge Engine cho phép admin tự tạo mẫu in → giảm phụ thuộc dev
### 3. Import dữ liệu
- Import Excel cho Products, Customers, Contracts đều hoạt động
- Xử lý được ngày tháng Excel (serial number), số điện thoại phức tạp
- Logic "bắc cầu" 2 file hợp đồng khá thông minh
---
## II. ĐIỂM YẾU & LỖI TIỀM ẨN (Cần sửa gấp)
### 🔴 Nghiêm trọng - Có thể crash/mất dữ liệu
| # | Vấn đề | Mô tả | Hệ quả |
|---|--------|-------|--------|
| 1 | **~~MailMergeService dùng `eval()`~~ [ĐÃ SỬA]** | `safeEval()` execute string bằng `eval('return ' . $expression)` | Nếu sanitize lỗi → Remote Code Execution. Hiện filter regex chưa đủ chặt |
| 2 | **~~`Contract::saved()` gọi `saveQuietly()`~~ [ĐÃ SỬA]** | Sau khi save HĐ, trigger tính toán rồi save lại | Nếu logic thay đổi → infinite loop. Hiện tại may mắn không loop vì chỉ update `calculation_log` nhưng rủi ro cao |
| 3 | **~~Không có Transaction~~ [ĐÃ SỬA]** | `ImportContractsComplex` dùng `DB::beginTransaction` nhưng các service khác không | Nếu tạo HĐ thành công nhưng tạo lịch TT lỗi → dữ liệu lệch |
| 4 | **~~Không có Soft Delete~~ [ĐÃ SỬA]** | Đã thêm SoftDeletes cho Contract, Payment, Customer + Restore/ForceDelete UI | Xóa nhầm → có thể khôi phục, audit được |
### 🟡 Trung bình - Ảnh hưởng trải nghiệm
| # | Vấn đề | Mô tả |
|---|--------|-------|
| 5 | **Chưa có phân quyền** | Chỉ có 1 loại user, ai cũng vào được mọi chức năng | Nhân viên thu ngân có thể xóa HĐ, sửa giá |
| 6 | **Chưa có API** | Hiện chỉ có Filament Admin Panel | Không làm app mobile, không tích hợp với website bán hàng |
| 7 | **ContractForm chưa hiển thị `calculation_log` đúng** | Khi create HĐ mới, `final_value_display` dùng `DiscountEngine` cũ thay vì Pipeline | Giá trị hiển thị có thể khác với giá trị lưu DB |
| 8 | **~~Payment chưa liên kết ngườ thu~~ [ĐÃ SỬA]** | Đã thêm `collected_by` (foreign key → users) + hiển thị trong Form/Table | Ghi nhận rõ ngườ thu tiền |
| 9 | **Chưa có sổ quỹ** | Thu tiền nhưng không ghi nhận vào quỹ tiền mặt/ngân hàng | Không đối soát được thực thu với ngân hàng |
### 🟢 Thấp - Cần cải thiện lâu dài
| # | Vấn đề | Mô tả |
|---|--------|-------|
| 10 | **Chưa có CRM Pipeline** | Không quản lý khách hàng tiềm năng (Lead) | Mất dữ liệu khách hàng đến xem nhà nhưng chưa mua |
| 11 | **~~Không có đợt mở bán~~ [ĐÃ SỬA]** | Sản phẩm chỉ có status "Đang mở bán", không có đợt/bLOCK mở bán riêng | Không áp dụng chính sách giá khác nhau theo đợt |
| 12 | **Chưa có báo cáo BCTC** | Chỉ có Dashboard widget đơn giản | Kế toán không lấy được báo cáo theo quý/năm để nộp thuế |
| 13 | **Không có quản lý hạ tầng sau bán** | `infrastructure_status` chỉ là JSONB tĩnh | Không theo dõi bảo hành đường, điện, nước |
---
## III. SO SÁNH VỚI CHUẨN NGÀNH BĐS VIỆT NAM
| Tiêu chí | HQLand hiện tại | Phần mềm BĐS chuyên nghiệp (Landsoft, REE) | Khoảng cách |
|----------|----------------|------------------------------------------|-------------|
| **CRM Lead** | Không có | Quản lý khách đến từ Facebook, sàn... | 🔴 Thiếu |
| **Pipeline bán hàng** | Không có | Lead → Chăm sóc → Giữ chỗ → HĐMB | 🔴 Thiếu |
| **Đợt mở bán** | ✅ Có - SalesPhase module | Mở bán Phase 1, 2, 3 với giá/chính sách khác nhau | 🟢 Tương đương |
| **Chính sách giá động** | Chiết khấu tĩnh | Chiết khấu theo đợt, theo khách hàng VIP, theo số lượng | 🟡 Cơ bản |
| **Tài chính** | Thu tiền + công nợ | BCTC, dòng tiền, đối soát ngân hàng | 🟡 Cơ bản |
| **In ấn** | Form Template | In HĐ, phiếu thu, phiếu tính giá | 🟢 Tương đương |
| **Phân quyền** | Không có | Role: Admin, Sales, Kế toán, Thu ngân... | 🔴 Thiếu |
| **Mobile App** | Không có | App cho sales, app cho khách hàng | 🔴 Thiếu |
**Nhận xét:** HQLand hiện tại mới đạt **30-40%** so với phần mềm BĐS chuyên nghiệp. Phù hợp làm **hệ thống nội bộ quản lý dữ liệu + thu tiền**, nhưng chưa đủ để làm **phần mềm bán hàng toàn diện**.
---
## IV. ĐỀ XUẤT LỘ TRÌNH PHÁT TRIỂN
### Giai đoạn 1: Sửa lỗi & An toàn (ĐÃ HOÀN THÀNH)
1. **~~Thay thế `eval()`~~ [DONE]** - Dùng shunting yard + bcmath trong MailMergeService
2. **~~Thêm `DB::transaction`~~ [DONE]** - `ContractScheduleService::generateFromTemplate()` đã có transaction
3. **~~Thêm Soft Delete~~ [DONE]** - Contract, Payment, Customer + Restore/ForceDelete UI
4. **~~Thêm `collected_by`~~ [DONE]** - Bảng `payments` + Form/Table hiển thị ngườ thu
5. **~~Fix `Contract::saved()`~~ [DONE]** - Dùng `self::$calculating` guard flag + `updateQuietly()`
### Giai đoạn 2: Quyền hạn & Báo cáo (2 tuần)
6. **Cài Spatie Permission** - Phân quyền: Admin, Sales Manager, Sales, Kế toán, Thu ngân
7. **Báo cáo công nợ chi tiết** - Theo khách hàng, theo dự án, theo đợt TT
8. **Báo cáo thu chi** - Sổ quỹ tiền mặt, sổ quỹ ngân hàng
9. **Export Excel báo cáo** - Báo cáo doanh thu, công nợ cho kế toán
### Giai đoạn 3: Mở rộng nghiệp vụ (Đang làm)
10. **CRM Pipeline** - Lead → Opportunity → Contract với các stage tùy chỉnh
11. **~~Quản lý đợt mở bán~~ [DONE]** - SalesPhase module với giá bán và chiết khấu riêng theo đợt
12. **Chính sách bán hàng động** - Chiết khấu theo thờ điểm, theo số lượng, theo CTV
13. **Notification đợt TT** - Email/SMS nhắc thanh toán tự động
### Giai đoạn 4: Tích hợp & Tối ưu (2 tháng)
14. **API REST** cho mobile app / website bán hàng
15. **Đối soát ngân hàng** - Import sao kê ngân hàng, tự động match với phiếu thu
16. **Quản lý bảo hành** - Theo dõi sửa chữa hạ tầng, bàn giao nhà
17. **Báo cáo BCTC** - Theo chuẩn kế toán Việt Nam, có thể xuất cho thuế
---
## V. KHUYẾN NGHỊ CHO BẠN
### Nên làm NGAY (tuần tới):
> Sửa lỗi nghiêm trọng #1, #2, #3. Không sửa → hệ thống có thể crash hoặc mất dữ liệu khi dữ liệu lớn lên.
### Nên làm TIẾP THEO (tháng tới):
> Phân quyền + Báo cáo tài chính. Đây là yêu cầu tối thiểu để kế toán và ban giám đốc sử dụng được.
### Có thể ĐỂ SAU:
> Mobile app, CRM Pipeline, đối soát ngân hàng. Các tính năng này tốn nhiều thời gian nhưng chưa ảnh hưởng đến vận hành cơ bản.
---
*Đánh giá này dựa trên code review thực tế và so sánh với best practices trong ngành BĐS Việt Nam. Cần cập nhật định kỳ.*

60
FILAMENT_LAYOUT_NOTES.md Normal file
View File

@@ -0,0 +1,60 @@
# FILAMENT LAYOUT NOTES - BÀI HỌC KHÔNG QUÊN
> Ghi chú nhanh để tránh lặp lại lỗi layout
> **Ngày:** 24/04/2026
---
## ⚠️ VẤN ĐỀ ĐÃ GẶP
Form tạo biểu mẫu (FormTemplate) bị chia cột: Section 1 và Section 2 nằm cùng hàng ngang thay vì xếp dọc full width.
## ✅ GIẢI PHÁP
### 1. Section xếp dọc full width
```php
Section::make('Tên section')
->columnSpanFull() // <-- BẮT BUỘC nếu muốn full width
->schema([...])
```
### 2. Field chia cột BÊN TRONG Section
```php
Section::make('Thông tin')
->columnSpanFull()
->schema([
Grid::make(3) // Grid chỉ dùng BÊN TRONG Section
->schema([
TextInput::make('name'),
TextInput::make('code'),
Select::make('type'),
]),
])
```
### 3. KHÔNG dùng Grid bọc ngoài nhiều Section
```php
// ❌ SAI - Grid bọc ngoài sẽ ép Section vào cột
Grid::make(2)->schema([
Section::make('A')->schema([...]),
Section::make('B')->schema([...]),
])
// ✅ ĐÚNG - Section xếp dọc, Grid bên trong
Section::make('A')->columnSpanFull()->schema([
Grid::make(3)->schema([...])
]),
Section::make('B')->columnSpanFull()->schema([
Grid::make(3)->schema([...])
]),
```
### 4. RichEditor tăng chiều cao
```php
RichEditor::make('content')
->extraInputAttributes(['style' => 'min-height: 500px;'])
```
---
## 📌 TÓM TẮT 1 DÒNG
> `Section` cần `->columnSpanFull()` để full width. `Grid::make(3)` chỉ dùng bên trong Section để chia field.

View File

@@ -1,64 +1,40 @@
# HQLAND - HƯỚNG DẪN PHIÊN LÀM VIỆC TIẾP THEO # HQLAND - HƯỚNG DẪN PHIÊN LÀM VIỆC TIẾP THEO
> File này giúp AI Agent nhanh chóng bắt nhịp khi bạn chuyển sang máy tính khác. > File này giúp AI Agent nhanh chóng bắt nhịp khi bạn chuyển sang máy tính khác.
> **Cập nhật:** 24/04/2026 > **Cập nhật:** 28/04/2026
---
## ⚠️ THÔNG BÁO QUAN TRỌNG
**rất nhiều file thay đổi CHƯA COMMIT**. Bạn cần commit trước khi chuyển máy!
```bash
git add -A
git commit -m "Hoan thien core finance v2 - Calculation Pipeline, Form Templates"
git push origin main
```
--- ---
## 1. NHỮNG GÌ VỪA HOÀN THÀNH ## 1. NHỮNG GÌ VỪA HOÀN THÀNH
### ✅ Kiến trúc mới: Calculation Pipeline ### ✅ Module mới: Sales Phases (Đợt mở bán)
- Tính toán giá BĐS tường minh, step-by-step với làm tròn tại mỗi bước - **Models:** `SalesPhase`, `SalesPhaseProduct` (pivot)
- `RoundingRule`: NONE, UNIT (đồng), THOUSAND, MILLION - **Migration:** `sales_phases`, `sales_phase_products`, `add_sales_phase_id_to_contracts`
- `CalculationStep`: Định nghĩa từng bước (tên, công thức, làm tròn, ghi đè) - **SalesPhaseResource:** Form + Table + Pages đầy đủ (Schemas)
- `CalculationResult`: Lưu snapshot + price_sheet cho phiếu tính giá - **ContractForm:** Chọn `sales_phase_id` → auto-populate giá từ pivot
- `PriceCalculationService`: Pipeline chuyên BĐS (QSDĐ + Móng → Subtotal → CK → Net → VAT → Total) - **CreateContract:** Fallback lấy `paymentTemplate` từ `salesPhase` nếu HĐ không chọn template trực tiếp
- `Contract::calculation_log`: JSONB lưu toàn bộ quá trình tính toán - **Product/Project models:** Thêm relationships với SalesPhase
### ✅ Module mới: Form Templates (Biểu mẫu in ấn) ### ✅ Kiến trúc cũ vẫn giữ nguyên
- **Mail Merge Engine:** Admin tự tạo template HTML, chèn `{{ma_truong}}` - Calculation Pipeline, Form Templates, Payment/Finance modules
- **FormField:** Định nghĩa nguồn dữ liệu (db_column, db_relation, formula, input, static) - Dashboard widgets, PaymentFine/Appendix/Settlement Resources
- **FormPrintLog:** Lưu snapshot khi in - 9 tests passing
- **FormTemplateResource:** CRUD trong Filament với RichEditor WYSIWYG + Repeater fields
- Layout: 3 section xếp dọc (Thông tin → Trường dữ liệu → Nội dung mẫu in)
### ✅ Các fix trước đó
- EditAction namespace, User FilamentUser, ContractForm tạo lịch tự động
- Payment validation, PaymentsTable đối soát, ContractsTable công nợ
- PaymentFine/Appendix/Settlement Resources, Dashboard widgets
--- ---
## 2. CẤU HÌNH DATABASE ## 2. CẤU HÌNH DATABASE
### Chạy migrate trên production (NẾU CHƯA CHẠY) ### Chạy migrate (NẾU CHƯA CHẠY)
```bash ```bash
DB_HOST=127.0.0.1 php artisan migrate --force DB_HOST=127.0.0.1 php artisan migrate --force
``` ```
Các migration quan trọng:
- `2026_04_24_083000_add_payment_template_id_to_contracts`
- `2026_04_28_013900_add_calculation_log_to_contracts`
- `2026_04_28_020000_create_form_templates_tables`
--- ---
## 3. TEST ## 3. TEST
```bash ```bash
DB_HOST=127.0.0.1 ./vendor/bin/pest --filter="ContractFinanceFlowTest|ContractResourceRenderTest" DB_HOST=127.0.0.1 ./vendor/bin/pest
``` ```
--- ---
@@ -79,3 +55,58 @@ DB_HOST=127.0.0.1 ./vendor/bin/pest --filter="ContractFinanceFlowTest|ContractRe
--- ---
*Commit ngay lập tức trước khi tắt máy!* *Commit ngay lập tức trước khi tắt máy!*
---
## PHIÊN TIẾP THEO - PHÂN QUYỀN (CÒN DỞ)
### Đã có:
- [x] Migration: permission_modules, role_templates, users columns
- [x] Models: PermissionModule, RoleTemplate
- [x] Command: `php artisan permissions:sync`
- [x] User Model: getEffectivePermissions(), hasEffectivePermission(), can() override
- [x] RoleTemplateResource: Form + Table + Pages (UI tạo mẫu nhóm)
- [x] UserResource: Form + Table + Pages (UI gán quyền user)
- [x] permissionActions trong 10 Resource
### CHƯA CÓ (Ưu tiên):
1. [ ] Áp dụng can() checks vào TẤT CẢ Resource
- Chưa có canViewAny(), canCreate(), canEdit(), canDelete()... trong Resource
- Cần thêm vào hoặc tạo base trait để auto check
- Hiện tại tất cả user vẫn full quyền!
2. [ ] Tạo seeder/sample data cho role_templates
- Admin: full quyền
- Sales: contracts CRUD, customers CRUD, products view
- Kế toán: payments CRUD, contracts view, reports view
3. [ ] Test User::can() override hoạt động đúng
- Login → tính effective permissions → lưu session
- Logout → xóa session
- can('contracts.create') → true/false đúng
### Cách áp dụng can() vào Resource (gợi ý):
```php
// Trong mỗi Resource class
public static function canViewAny(): bool
{
return auth()->user()?->can('contracts.view') ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->can('contracts.create') ?? false;
}
public static function canEdit($record): bool
{
return auth()->user()?->can('contracts.update') ?? false;
}
public static function canDelete($record): bool
{
return auth()->user()?->can('contracts.delete') ?? false;
}
```
### Lưu ý:
- Không chạy `php artisan permissions:sync` tự động (chạy tay khi thêm module)
- Action mới mặc định TẮT
- Layout RoleTemplateForm đã fix full width

566
WORKFLOW.md Normal file
View File

@@ -0,0 +1,566 @@
# HQLAND - WORKFLOW TOÀN HỆ THỐNG
> Tài liệu này mô tả chi tiết luồng dữ liệu, nghiệp vụ và tương tác giữa các module trong HQLand.
> **Cập nhật:** 29/04/2026
---
## I. TỔNG QUAN KIẾN TRÚC
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ FILAMENT ADMIN PANEL v5.5 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Warehouse │ │ CRM │ │ Contracts │ │ Finance │ │
│ │ (Kho hàng) │ │ (Khách hàng)│ │ (Hợp đồng) │ │ (Thu tiền/Báo │ │
│ │ │ │ │ │ │ │ cáo) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │ │
│ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ ┌───────┴─────────┐ │
│ │ Project │ │ Customer │ │ Contract │ │ Payment │ │
│ │ Product │ │ (INDIVIDUAL│ │ Appendix │ │ PaymentFine │ │
│ │ │ │ /COMPANY) │ │ Settlement │ │ Settlement │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ SUPPORT MODULES ││
│ │ PaymentTemplate → PaymentSchedule → PaymentScheduleItem ││
│ │ SalesPhase → SalesPhaseProduct (Pivot) ││
│ │ FormTemplate → FormField → FormPrintLog ││
│ │ MailMergeService | DiscountEngine | PriceCalculationService ││
│ └─────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ POSTGRESQL (UUID Primary Keys) │
│ 120 Customers | 45 Products | 139 Contracts | Notifications Table │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Tech Stack
- **Framework:** Laravel 13.x + PHP 8.3
- **Admin Panel:** Filament v5.5 (Schemas Architecture)
- **Database:** PostgreSQL + UUID (100% tables)
- **Excel:** PhpSpreadsheet 5.7
- **Testing:** Pest PHP 4.6
---
## II. LUỒNG DỮ LIỆU TỔNG THỂ (END-TO-END)
### 1. Khởi tạo Dự án & Kho hàng
```
Project (Hà Quang 1)
├── PaymentTemplate (Mẫu lịch thanh toán mặc định)
│ └── PaymentScheduleItem x N đợt (% + ngày đến hạn)
└── Product x N (Lô đất/Căn hộ)
├── Giá gốc (qsdd_value, foundation_temp_value, total_price)
├── Hạ tầng (infrastructure_status - JSONB)
└── Custom data (block, building_density, legal_status)
```
### 2. Khởi tạo Khách hàng
```
Customer (INDIVIDUAL hoặc COMPANY)
├── INDIVIDUAL: full_name, cmnd_cccd, phone, addresses...
└── COMPANY: full_name (tên cty), tax_code, representative_id
└── representative → Customer (INDIVIDUAL)
```
### 3. Luồng Bán hàng chuẩn
```
Bước 1: Tạo Đợt mở bán (SalesPhase)
├── Chọn Project
├── Chọn PaymentTemplate (mẫu lịch thanh toán)
├── Định chính sách chiết khấu mặc định (discount_policy JSONB)
└── Thêm sản phẩm vào đợt (SalesPhaseProduct pivot)
├── Giá riêng cho đợt (sale_price/land_value/foundation_value)
├── Chiết khấu riêng (discount_details JSONB)
└── Status: Còn hàng | Đã giữ | Đã bán | Khóa
Bước 2: Tạo Hợp đồng (Contract)
├── Chọn Product (Lô đất)
├── [Optional] Chọn SalesPhase → Auto populate giá từ SalesPhaseProduct
├── Nhập giá trị tài chính:
│ land_value + foundation_value = total_value (auto)
├── Chiết khấu (discount_details JSONB)
├── Chọn Khách hàng (belongsToMany qua contract_customers)
├── Chọn PaymentTemplate (để tạo lịch thanh toán sau khi lưu)
└── [Auto] Khi save:
├── Booted: total_value = land_value + foundation_value
├── Saved: PriceCalculationService tạo calculation_log
└── AfterCreate: ContractScheduleService tạo PaymentSchedule
Bước 3: PaymentSchedule tự động tạo
├── Contract → PaymentSchedule (1-1)
└── PaymentSchedule → PaymentScheduleItem x N
├── Mỗi item = 1 đợt thanh toán
├── amount = total_value × (percentage / 100)
├── due_date tính từ signing_date + days_after_signing
└── Sắp xếp theo installment_no
Bước 4: Thu tiền (Payment)
├── Chọn Contract
├── [Optional] Chọn PaymentScheduleItem (đợt thanh toán)
│ Nếu không chọn = Tạm ứng / không đối soát đợt
├── Nhập amount, paid_date, method, receipt_number
├── [Validation] amount không vượt quá công nợ đợt / công nợ HĐ
└── [Auto - PaymentObserver] Khi Payment created/updated/deleted:
├── recalculateContract: SUM payments → paid_amount
│ remaining_amount = total_value - paid_amount
│ excess_amount = paid_amount - total_value (nếu > 0)
└── applySurplusToNextInstallment:
Nếu excess_amount > 0 → Auto tạo Payment mới cho đợt tiếp theo
```
### 4. In ấn (Form Templates)
```
FormTemplate (Admin tạo mẫu)
├── HTML content với placeholder {{ma_truong}}
└── FormField x N:
├── db_column: Lấy trực tiếp cột từ Model
├── db_relation: Lấy qua relation (customers.0.full_name)
├── formula: Tính toán (dùng Shunting Yard + bcmath)
├── input: Nhập tay khi in
└── static: Giá trị cố định
Khi in:
MailMergeService::render(template, record)
├── evaluateFields() → Tính giá trị tất cả fields
├── Thay placeholder → rendered HTML
└── savePrintLog() → Lưu snapshot + rendered HTML + user + timestamp
```
---
## III. CHI TIẾT MODULE
### 3.1. WAREHOUSE (Kho hàng)
#### Models
| Model | Key Fields | Quan hệ |
|-------|-----------|---------|
| **Project** | code, name, type, address, payment_template_id | hasMany Products, hasMany SalesPhases, hasMany PaymentTemplates |
| **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), status | belongsTo Project, hasMany Contracts, belongsToMany SalesPhases |
#### Nghiệp vụ
1. **Import từ Excel** (`import:products-excel`):
- File: `sanpham.xlsx`
- Parse hạ tầng từ chuỗi "Key: Value - Key2: Value2" → `infrastructure_status` JSONB
- Parse custom_data: block, building_density, legal_status_raw
- `updateOrCreate` theo `code + project_id`
2. **Tìm đợt mở bán đang active** (`Product::activeSalesPhase()`):
- SalesPhase.status = "Đang mở bán"
- SalesPhaseProduct.status = "Còn hàng"
- Ngày hiện tại nằm trong [start_date, end_date]
---
### 3.2. CRM (Khách hàng)
#### Models
| Model | Key Fields | Quan hệ |
|-------|-----------|---------|
| **Customer** | type (INDIVIDUAL/COMPANY), full_name, cmnd_cccd, tax_code, title, phone, secondary_phones (JSONB), email, dob, permanent_address, contact_address, id_issue_date, id_issue_place, representative_id | belongsToMany Contracts (via contract_customers), representative → Customer, representedCompanies → Customer[] |
#### Nghiệp vụ
1. **Import từ Excel** (`import:customers-excel`):
- File: `khachhang.xlsx`
- Tách nhiều số điện thoại (dấu phẩy, gạch chéo, xuống dòng) → `phone` + `secondary_phones`
- Parse ngày Excel serial number → Carbon
- Tạo mẫu: Công ty TNHH BĐS Thịnh Vượng + Ngườ đại diện
2. **Form tạo/sửa** (CustomerForm):
- Chuyển đổi động INDIVIDUAL/COMPANY (live)
- INDIVIDUAL: Hiện dob, id_issue_date, id_issue_place
- COMPANY: Hiện tax_code, representative_id (chọn từ danh sách cá nhân)
- Copy địa chỉ thường trú → liên hệ (suffixAction)
---
### 3.3. CONTRACTS (Hợp đồng)
#### Models
| Model | Key Fields | Quan hệ |
|-------|-----------|---------|
| **Contract** | 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), calculation_log (JSONB), brokerage_name, stored_contract_count, filing_note, transfer_order, payment_template_id, sales_phase_id | belongsTo Product, belongsToMany Customers (via contract_customers + pivot role/transfer_order), belongsTo PaymentTemplate, belongsTo SalesPhase, hasOne PaymentSchedule, hasManyThrough PaymentScheduleItem, hasMany Payments, hasMany PaymentFines, hasMany Appendices |
#### Nghiệp vụ
**A. Tạo hợp đồng mới (CreateContract)**
```
1. Chọn Product → Auto điền:
- total_value = product.total_price
- land_value = product.qsdd_value
- foundation_value = product.foundation_temp_value
2. [Optional] Chọn SalesPhase → Auto điền từ SalesPhaseProduct:
- land_value, foundation_value, sale_price
- Nếu có sale_price → total_value = sale_price
- discount_details từ pivot
3. Nhập tay land_value, foundation_value → total_value auto cập nhật
4. Nhập discount_details (Key-Value) + xem tổng quan chiết khấu
5. Chọn Customers (multiple), nhập các thông tin quản lý
6. Chọn payment_template_id → [Auto] Sau khi lưu tạo PaymentSchedule
```
**B. Booted Logic (Model Contract)**
```
static::saving:
land_value + foundation_value → total_value
Nếu tạo mới và chưa có giá → fallback lấy product.total_price
remaining_amount = total_value - paid_amount
static::saved:
Nếu có land_value/foundation_value:
PriceCalculationService::calculateForContract() → calculation_log
Cập nhật calculation_log (steps, final_values, price_sheet)
```
**C. Calculation Pipeline**
```
Input: land_value, foundation_value, discount_details, vat_rate
Step 1: Giá trị QSDĐ → land_value
Step 2: Giá trị Móng → foundation_value
Step 3: Subtotal = land_value + foundation_value
Step 4: Chiết khấu = total_amount HOẶC total_percentage × subtotal
Step 5: Net value = subtotal - discount
Step 6: VAT = net_value × vat_rate%
Step 7: Total payment = net_value + VAT
Output: calculation_log lưu snapshot tất cả bước
```
**D. Lịch thanh toán (ContractScheduleService)**
```
generateFromTemplate(contract, template):
DB::transaction:
Xóa lịch cũ (nếu có)
Tạo PaymentSchedule
Duyệt template.items theo installment_no:
due_date = signing_date + days_after_signing
amount = contract.total_value × (percentage / 100)
Tạo PaymentScheduleItem
```
**E. Các module phụ**
- **Appendix (Phụ lục):** belongsTo Contract + Product, custom_data JSONB
- **Settlement (Quyết toán):** belongsTo Product, temp_value, final_value, difference
- **PaymentFine (Tiền phạt):** belongsTo Contract, amount, due_date, paid_date
---
### 3.4. FINANCE (Tài chính & Thu tiền)
#### Models
| Model | Key Fields | Quan hệ |
|-------|-----------|---------|
| **Payment** | contract_id, schedule_item_id, amount, paid_date, method, receipt_number, metadata (JSONB) | belongsTo Contract, belongsTo PaymentScheduleItem |
| **PaymentSchedule** | contract_id, template_id | belongsTo Contract, belongsTo PaymentTemplate, hasMany Items |
| **PaymentScheduleItem** | schedule_id, installment_no, type (PaymentType enum), percentage, amount, due_date, days_after_signing, days_after_previous | belongsTo PaymentSchedule, hasMany Payments |
#### PaymentType Enum
| Value | Label |
|-------|-------|
| QSDD | Tiền QSDĐ |
| MONG | Tiền Móng |
| THAN | Tiền Thân |
| CHI_PHI_TC | Chi phí thi công |
| CK | Chiết khấu |
| PHAT | Tiền phạt |
| OTHER | Khác |
#### Nghiệp vụ
**A. Thu tiền (PaymentForm)**
```
1. Chọn Contract → Cascade: Đợt thanh toán lọc theo contract
2. Chọn Đợt TT (optional)
- Để trống = Tạm ứng / Không đối soát đợt
3. Nhập amount
- Helper text hiển thị công nợ đợt / công nợ HĐ
- Validation: Không cho phép thu quá công nợ
4. Nhập paid_date, method, receipt_number
5. Lưu
```
**B. PaymentObserver (Tự động)**
```
created/updated/deleted:
├── recalculateContract(contract):
│ totalPaid = SUM(payments.amount)
│ paid_amount = totalPaid
│ Nếu totalPaid > total_value:
│ remaining_amount = 0
│ excess_amount = totalPaid - total_value
│ Ngược lại:
│ remaining_amount = total_value - totalPaid
│ excess_amount = 0
│ saveQuietly()
└── applySurplusToNextInstallment(contract):
Nếu excess_amount > 0:
Tìm đợt tiếp theo chưa thanh toán đủ (theo installment_no)
applyAmount = min(excess, remaining_for_item)
Tạo Payment auto:
method = "Tự động khấu trừ"
receipt_number = "AUTO-SURPLUS-..."
metadata = {auto_surplus: true}
```
**C. Bảng thu tiền (PaymentsTable)**
- Cột: Hợp đồng, Số tiền, Ngày thu, Phương thức, Số phiếu thu
- Cột mở rộng: Loại đợt, Đợt TT, Trạng thái đối soát (Đủ/Thiếu/Thừa/Tạm ứng), Còn thiếu
- Filter: Phương thức, Ngày thu (range)
- Eager load: `scheduleItem.payments`
---
### 3.5. SALES PHASES (Đợt mở bán)
#### Models
| Model | Key Fields | Quan hệ |
|-------|-----------|---------|
| **SalesPhase** | project_id, name, code, status (Chuẩn bị/Đang mở bán/Tạm dừng/Đã đóng), start_date, end_date, description, payment_template_id, discount_policy (JSONB) | belongsTo Project, belongsTo PaymentTemplate, hasMany phaseProducts, belongsToMany Products |
| **SalesPhaseProduct** (Pivot) | sales_phase_id, product_id, sale_price, land_value, foundation_value, discount_details (JSONB), status (Còn hàng/Đã giữ/Đã bán/Khóa) | belongsTo SalesPhase, belongsTo Product |
#### Nghiệp vụ
1. **Tạo đợt mở bán:**
- Thông tin cơ bản (name, code, status, dates)
- Chính sách & Mẫu thanh toán
- Danh sách sản phẩm (Repeater) với giá riêng và chiết khấu riêng
2. **Tích hợp vào ContractForm:**
- Khi chọn `sales_phase_id` + `product_id` → Auto populate giá từ pivot
- CreateContract: Nếu không chọn payment_template_id trực tiếp → fallback lấy từ `salesPhase->paymentTemplate`
---
### 3.6. FORM TEMPLATES (Biểu mẫu in ấn)
#### Models
| Model | Key Fields | Quan hệ |
|-------|-----------|---------|
| **FormTemplate** | name, code, target_model (Contract/Product/Customer), html_template, paper_size (A4/A5/Letter), is_active | hasMany fields, hasMany printLogs |
| **FormField** | template_id, code, label, source_type (db_column/db_relation/formula/input/static), source_config (JSONB), format (text/number/currency/date/percent), decimal_places, display_order | belongsTo template |
| **FormPrintLog** | template_id, target_model, target_id, target_number, snapshot_data (JSONB), rendered_html, printed_by, printed_at | belongsTo template, belongsTo printedBy (User) |
#### MailMergeService Workflow
```
evaluateFields(template, record):
foreach field:
db_column → record->{column}
db_relation → record->{relation}[index]->{column}
formula → Shunting Yard evaluate (bcmath)
input → default value
static → fixed value
render(template, record):
evaluateFields() → raw_values
formatValue() theo format/currency/date/percent
str_replace placeholder {{code}} → value trong html_template
savePrintLog():
Lưu snapshot_data + rendered_html + user + timestamp
```
---
## IV. DASHBOARD & BÁO CÁO
### Widgets
| Widget | Dữ liệu |
|--------|---------|
| **RecentNotifications** | Thông báo chưa đọc của user hiện tại (database notifications) |
| **ContractStatsOverview** | Tổng doanh thu, Đã thu, Công nợ phải thu, HĐ hiệu lực, Đợt TT sắp đến hạn |
| **UpcomingPaymentsTable** | PaymentScheduleItem trong 30 ngày tới (có đủ công nợ) |
### Pages
| Page | Chức năng |
|------|-----------|
| **ProjectReport** | Bảng thống kê theo dự án: Tổng SP, Đã bán, Số HĐ, Tổng giá trị, Đã thu, Công nợ |
### Commands
| Command | Chức năng |
|---------|-----------|
| `export:debt-report` | Xuất Excel 2 sheet: Tổng hợp công nợ + Chi tiết đợt TT |
| `notifications:send-due-payments` | Gửi cảnh báo đợt TT sắp đến hạn cho tất cả users (database notification) |
| `contracts:generate-schedules` | Tạo lịch thanh toán hàng loạt cho HĐ chưa có lịch |
---
## V. CÁC COMMAND IMPORT DỮ LIỆU
### `import:products-excel {file=sanpham.xlsx}`
- Input: File Excel sản phẩm
- Output: Product records trong Project "Hà Quang 1"
- Logic: `updateOrCreate` theo code, parse infrastructure_status, parse custom_data
### `import:customers-excel {file=khachhang.xlsx}`
- Input: File Excel khách hàng
- Output: Customer records (INDIVIDUAL) + Mẫu Công ty
- Logic: Tách nhiều phone, parse Excel date serial
### `import:contracts-complex {hopdong=hopdong.xlsx} {hdkh=Hd_kh.xlsx}`
- Input: 2 file (tài chính + liên kết KH)
- Logic "Bắc cầu":
1. Đọc hopdong.xlsx → Map `contract_number → finance_data`
2. Đọc Hd_kh.xlsx → Tìm product theo plotCode, customer theo cmnd_cccd
3. Tìm mapping: `str_contains(contract_number, plotCode)`
4. `updateOrCreate` Contract + `syncWithoutDetaching` customers qua pivot
- Transaction bọc toàn bộ
### `contracts:generate-schedules {--force}`
- Input: HĐ chưa có PaymentSchedule (hoặc --force để tạo lại)
- Logic: Ưu tiên `contract.paymentTemplate`, fallback `product.project.paymentTemplate`
---
## VI. MÔ HÌNH QUAN HỆ DATABASE (ER Tóm tắt)
```
Project 1───* Product 1───* Contract *───* Customer
│ │ │
│ │ 1───1 PaymentSchedule 1───* PaymentScheduleItem
│ │ │ │
│ │ *───* Payment *───────────┘
│ │ │
│ │ *───* Appendix
│ │ *───* PaymentFine
│ │ *───* Settlement (qua Product)
│ │
*───* SalesPhase *───* SalesPhaseProduct *───1 Product
│ │
│ 1───1 PaymentTemplate 1───* PaymentScheduleItem (template)
*───* PaymentTemplate
FormTemplate 1───* FormField
1───* FormPrintLog
User 1───* Notification (MorphMany)
```
---
## VII. ĐIỂM CẦN XEM XÉT (TỪ ASSESSMENT)
| # | Vấn đề | Mức độ | Ghi chú |
|---|--------|--------|---------|
| 1 | **~~Soft Delete~~ ✅** | 🟢 Đã xong | Contract, Payment, Customer có SoftDeletes + Restore/ForceDelete UI |
| 2 | **Phân quyền** | 🟡 Đang thiết kế | Kiến trúc Hướng B (Tự viết, không Spatie). Xem chi tiết Phần VIII |
| 3 | **~~Payment.collected_by~~ ✅** | 🟢 Đã xong | Đã thêm collected_by (FK → users) + hiển thị Form/Table |
| 4 | **Sổ quỹ** | 🟡 Trung bình | Thu tiền nhưng không ghi vào quỹ TM/NH. Không đối soát được |
| 5 | **CRM Pipeline** | 🟢 Thấp | Chưa quản lý Lead/Khách hàng tiềm năng |
| 6 | **Báo cáo BCTC** | 🟢 Thấp | Chưa có báo cáo theo chuẩn kế toán VN |
---
---
## VIII. PERMISSION SYSTEM DESIGN (Hướng B - Tự viết, không Spatie)
> **Quyết định kiến trúc:** Không dùng Spatie Permission. Tự viết module phân quyền đơn giản, đủ dùng, kiểm soát 100%.
> **Lý do:** Tránh config phức tạp, tránh xung đột UUID/BIGINT, phù hợp 5-10 role, không cần advanced features.
### Kiến trúc 3 lớp
```
LAYER 1: ROLE TEMPLATE (Mẫu nhóm)
role_templates.id(UUID) | name | description | permissions(JSONB) | is_active
permissions lưu dạng:
{
"contracts": ["view","create","update","delete","restore","forceDelete","export"],
"payments": ["view","create","update","delete"],
"customers": ["view","create","update","delete"]
}
LAYER 2: USER (Kế thừa + Override)
users.role_template_id(UUID) → nullable
users.extra_permissions(JSONB) → thêm quyền vượt cấp
users.excluded_permissions(JSONB) → bớt quyền so với mẫu
LAYER 3: EFFECTIVE PERMISSIONS (Tính toán động, cache trong Session)
effective = template_permissions + extra_permissions - excluded_permissions
Tính 1 lần khi user login → lưu vào session()->get('user.{id}.permissions')
Tự động xóa khi logout
Có thể xóa thủ công: auth()->user()->clearPermissionCache()
```
### Quy tắc khai báo Permission trong Resource
Mỗi Resource khai báo:
```php
class ContractResource extends Resource
{
protected static array $permissionActions = [
'view', 'create', 'update', 'delete',
'restore', 'forceDelete', 'export'
];
protected static string $permissionLabel = 'Hợp đồng';
}
```
Naming: `{snake_case_resource_name}.{action}` (ví dụ: `contracts.create`, `payments.delete`)
### Command đồng bộ (THỦ CÔNG - KHÔNG TỰ ĐỘNG)
```bash
php artisan permissions:sync
```
**Khi nào chạy:**
- Khi thêm Resource mới
- Khi thêm/bớt `$permissionActions` trong Resource
- KHÔNG chạy tự động trong composer post-autoload-dump
**Command làm gì:**
1. Quét tất cả Filament Resources, lấy `$permissionActions` + `$permissionLabel`
2. Lưu vào bảng `permission_modules` (module, label, actions)
3. Action mới mặc định **TẮT** cho tất cả role hiện tại (an toàn)
4. Admin phải vào bật thủ công sau
### Luồng thêm module/action mới
**Step 1: Dev code**
- Tạo Resource mới / thêm action vào Resource
- Thêm `$permissionActions` vào Resource class
**Step 2: Dev chạy command**
- `php artisan permissions:sync`
**Step 3: Admin cấu hình**
- Vào Role Template UI → thấy module/action mới (badge "Mới")
- Tick bật quyền cho các nhóm cần dùng
### Các hàm kiểm tra quyền
```php
// User Model
public function getEffectivePermissions(): array;
public function hasEffectivePermission(string $permission): bool;
public function clearPermissionCache(): void;
// Trong Resource
public static function canCreate(): bool
{
return auth()->user()->can('contracts.create');
}
```
### UI quản lý (Filament Resources)
| Resource | Chức năng |
|----------|-----------|
| **RoleTemplateResource** | CRUD mẫu nhóm. Form chọn module → tick actions. CheckboxList per module |
| **UserPermissionPage** | Chọn user → hiện role template đang dùng → thêm/bớt quyền chi tiết → preview effective permissions |
---
*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 nghiệp vụ.*

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Console\Commands;
use App\Models\Contract;
use App\Models\PaymentScheduleItem;
use Illuminate\Console\Command;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class ExportDebtReport extends Command
{
protected $signature = 'export:debt-report
{--output=storage/app/reports/bao-cao-cong-no.xlsx : Đường dẫn file xuất}
{--project= : Lọc theo UUID dự án}';
protected $description = 'Xuất báo cáo công nợ khách hàng ra file Excel';
public function handle(): int
{
$outputPath = $this->option('output');
$projectId = $this->option('project');
// Đảm bảo thư mục tồn tại
$dir = dirname($outputPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$this->info('Đang tải dữ liệu...');
// ===== Sheet 1: Tổng hợp công nợ =====
$contractsQuery = Contract::query()
->with(['customers', 'product.project', 'paymentSchedule.items'])
->when($projectId, fn ($q) => $q->whereHas('product', fn ($q2) => $q2->where('project_id', $projectId)))
->orderBy('contract_number');
$contracts = $contractsQuery->get();
$spreadsheet = new Spreadsheet();
$sheet1 = $spreadsheet->getActiveSheet();
$sheet1->setTitle('Tổng hợp công nợ');
// Header
$headers1 = ['STT', 'Số HĐMB', 'Khách hàng', 'Dự án', 'Lô đất', 'Giá trị HĐ (VNĐ)', 'Đã thu (VNĐ)', 'Còn lại (VNĐ)', 'Trạng thái', 'Ngày ký'];
$this->writeHeader($sheet1, $headers1);
$row = 2;
foreach ($contracts as $index => $contract) {
$sheet1->setCellValue('A' . $row, $index + 1);
$sheet1->setCellValue('B' . $row, $contract->contract_number);
$sheet1->setCellValue('C' . $row, $contract->customers->pluck('full_name')->implode(', '));
$sheet1->setCellValue('D' . $row, $contract->product->project->name ?? '');
$sheet1->setCellValue('E' . $row, $contract->product->code ?? '');
$sheet1->setCellValue('F' . $row, (float) $contract->total_value);
$sheet1->setCellValue('G' . $row, (float) $contract->paid_amount);
$sheet1->setCellValue('H' . $row, (float) $contract->remaining_amount);
$sheet1->setCellValue('I' . $row, $contract->status);
$sheet1->setCellValue('J' . $row, $contract->signing_date ? $contract->signing_date->format('d/m/Y') : '');
$row++;
}
$this->formatNumberColumns($sheet1, ['F', 'G', 'H'], $row - 1);
$this->autoSizeColumns($sheet1, $headers1);
// ===== Sheet 2: Chi tiết đợt thanh toán chưa đủ =====
$sheet2 = $spreadsheet->createSheet();
$sheet2->setTitle('Chi tiết đợt TT');
$headers2 = ['STT', 'Số HĐMB', 'Khách hàng', 'Đợt', 'Loại', 'Ngày đến hạn', 'Số tiền đợt (VNĐ)', 'Đã thu (VNĐ)', 'Còn thiếu (VNĐ)'];
$this->writeHeader($sheet2, $headers2);
$itemsQuery = PaymentScheduleItem::query()
->with(['schedule.contract.customers', 'payments'])
->whereHas('schedule.contract')
->when($projectId, fn ($q) => $q->whereHas('schedule.contract.product', fn ($q2) => $q2->where('project_id', $projectId)))
->orderBy('due_date');
$items = $itemsQuery->get();
$row = 2;
$stt = 1;
foreach ($items as $item) {
$contract = $item->schedule?->contract;
if (! $contract) continue;
$paid = (float) $item->paid_amount;
$remaining = (float) $item->remaining_amount;
$sheet2->setCellValue('A' . $row, $stt);
$sheet2->setCellValue('B' . $row, $contract->contract_number);
$sheet2->setCellValue('C' . $row, $contract->customers->pluck('full_name')->implode(', '));
$sheet2->setCellValue('D' . $row, $item->installment_no);
$sheet2->setCellValue('E' . $row, $item->type?->getLabel() ?? (string) $item->type);
$sheet2->setCellValue('F' . $row, $item->due_date ? $item->due_date->format('d/m/Y') : '');
$sheet2->setCellValue('G' . $row, (float) $item->amount);
$sheet2->setCellValue('H' . $row, $paid);
$sheet2->setCellValue('I' . $row, $remaining);
$row++;
$stt++;
}
$this->formatNumberColumns($sheet2, ['G', 'H', 'I'], $row - 1);
$this->autoSizeColumns($sheet2, $headers2);
// Lưu file
$writer = new Xlsx($spreadsheet);
$writer->save($outputPath);
$this->info("Xuất báo cáo thành công: {$outputPath}");
$this->info("- Tổng hợp: {$contracts->count()} hợp đồng");
$this->info("- Chi tiết đợt: {$items->count()} dòng");
return self::SUCCESS;
}
private function writeHeader($sheet, array $headers): void
{
foreach ($headers as $colIndex => $header) {
$coord = [$colIndex + 1, 1];
$sheet->setCellValue($coord, $header);
$cell = $sheet->getCell($coord);
$cell->getStyle()->getFont()->setBold(true);
$cell->getStyle()->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB('E5E7EB');
$cell->getStyle()->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$cell->getStyle()->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
}
}
private function formatNumberColumns($sheet, array $columns, int $lastRow): void
{
foreach ($columns as $col) {
$sheet->getStyle("{$col}2:{$col}{$lastRow}")
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1);
}
}
private function autoSizeColumns($sheet, array $headers): void
{
foreach (range(1, count($headers)) as $colIndex) {
$colLetter = Coordinate::stringFromColumnIndex($colIndex);
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Console\Commands;
use App\Models\PaymentScheduleItem;
use App\Models\User;
use App\Notifications\PaymentDueNotification;
use Illuminate\Console\Command;
class SendPaymentDueNotifications extends Command
{
protected $signature = 'notifications:send-due-payments
{--days=7 : Số ngày trước hạn để cảnh báo}
{--dry-run : Chỉ liệt , không gửi}';
protected $description = 'Gửi cảnh báo đợt thanh toán sắp đến hạn cho tất cả users';
public function handle(): int
{
$days = (int) $this->option('days');
$dryRun = $this->option('dry-run');
$from = now();
$to = now()->addDays($days);
$items = PaymentScheduleItem::query()
->with(['schedule.contract', 'payments'])
->whereHas('schedule.contract')
->whereDate('due_date', '>=', $from)
->whereDate('due_date', '<=', $to)
->whereRaw('amount > (SELECT COALESCE(SUM(amount), 0) FROM payments WHERE payments.schedule_item_id = payment_schedule_items.id)')
->orderBy('due_date')
->get();
if ($items->isEmpty()) {
$this->warn('Không có đợt thanh toán nào sắp đến hạn trong ' . $days . ' ngày tới.');
return self::SUCCESS;
}
$this->info("Tìm thấy {$items->count()} đợt thanh toán sắp đến hạn.");
$users = User::all();
if ($users->isEmpty()) {
$this->warn('Không có user nào trong hệ thống để nhận thông báo.');
return self::FAILURE;
}
foreach ($items as $item) {
$contract = $item->schedule?->contract;
$remaining = (float) $item->remaining_amount;
$this->line(sprintf(
'- HĐ %s | Đợt %d | Ngày %s | Còn thiếu: %s VNĐ',
$contract?->contract_number ?? 'N/A',
$item->installment_no,
$item->due_date?->format('d/m/Y'),
number_format($remaining)
));
if (! $dryRun) {
foreach ($users as $user) {
$user->notify(new PaymentDueNotification($item));
}
}
}
if ($dryRun) {
$this->info('Chế độ dry-run: Không có thông báo nào được gửi.');
} else {
$this->info("Đã gửi thông báo cho {$users->count()} user(s).");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Console\Commands;
use App\Models\PermissionModule;
use App\Models\RoleTemplate;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
class SyncPermissions extends Command
{
protected $signature = 'permissions:sync {--dry-run : Chỉ liệt kê, không lưu}';
protected $description = 'Quét Filament Resources và đồng bộ permission modules. Action mới mặc định TẮT.';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$this->info('Đang quét Filament Resources...');
$resourcesPath = app_path('Filament/Resources');
if (! is_dir($resourcesPath)) {
$this->error('Không tìm thấy thư mục Filament/Resources');
return self::FAILURE;
}
$modules = [];
$finder = new Finder();
$finder->files()->in($resourcesPath)->name('*Resource.php');
foreach ($finder as $file) {
$class = $this->getClassFromFile($file->getRealPath());
if (! $class || ! class_exists($class)) {
continue;
}
$ref = new ReflectionClass($class);
if (! $ref->hasProperty('permissionActions')) {
continue;
}
$prop = $ref->getProperty('permissionActions');
$prop->setAccessible(true);
$actions = $prop->getDefaultValue();
$label = 'Resource';
if ($ref->hasProperty('pluralModelLabel')) {
$labelProp = $ref->getProperty('pluralModelLabel');
$labelProp->setAccessible(true);
$label = $labelProp->getDefaultValue() ?? $label;
}
$moduleName = Str::snake(class_basename($class));
$moduleName = str_replace('_resource', '', $moduleName);
$modules[] = [
'module' => $moduleName,
'label' => $label,
'actions' => $actions ?? [],
];
}
if (empty($modules)) {
$this->warn('Không tìm thấy Resource nào có $permissionActions.');
return self::SUCCESS;
}
$this->info('Tìm thấy ' . count($modules) . ' module(s):');
foreach ($modules as $m) {
$this->line(" - {$m['module']}: " . implode(', ', $m['actions']));
}
if ($dryRun) {
$this->info('Dry-run: Không lưu thay đổi.');
return self::SUCCESS;
}
foreach ($modules as $m) {
$existing = PermissionModule::where('module', $m['module'])->first();
if ($existing) {
$oldActions = $existing->actions ?? [];
$newActions = $m['actions'];
if ($oldActions === $newActions) {
continue;
}
$added = array_diff($newActions, $oldActions);
$removed = array_diff($oldActions, $newActions);
$existing->update([
'label' => $m['label'],
'actions' => $newActions,
]);
if (! empty($added)) {
$this->warn(" [{$m['module']}] Thêm actions: " . implode(', ', $added));
// Action mới mặc định TẮT cho tất cả role
$this->disableNewActionsForAllRoles($m['module'], $added);
}
if (! empty($removed)) {
$this->warn(" [{$m['module']}] Xóa actions: " . implode(', ', $removed));
$this->removeActionsFromAllRoles($m['module'], $removed);
}
} else {
PermissionModule::create([
'module' => $m['module'],
'label' => $m['label'],
'actions' => $m['actions'],
]);
$this->info(" [{$m['module']}] Tạo mới module.");
// Module mới mặc định TẮT cho tất cả role
$this->disableNewActionsForAllRoles($m['module'], $m['actions']);
}
}
$this->info('Đồng bộ hoàn tất.');
return self::SUCCESS;
}
private function getClassFromFile(string $path): ?string
{
$contents = file_get_contents($path);
// Tìm namespace
$namespace = null;
if (preg_match('/namespace\s+([^;]+);/', $contents, $matches)) {
$namespace = $matches[1];
}
// Tìm class name
$class = null;
if (preg_match('/class\s+(\w+)/', $contents, $matches)) {
$class = $matches[1];
}
if ($namespace && $class) {
return $namespace . '\\' . $class;
}
return null;
}
private function disableNewActionsForAllRoles(string $module, array $actions): void
{
$roles = RoleTemplate::all();
foreach ($roles as $role) {
$perms = $role->permissions ?? [];
foreach ($actions as $action) {
if (isset($perms[$module]) && in_array($action, $perms[$module])) {
continue; // Đã có thì giữ nguyên
}
// Không thêm vào = mặc định TẮT
}
// Không cần update vì JSONB không lưu action mới = TẮT
}
}
private function removeActionsFromAllRoles(string $module, array $actions): void
{
$roles = RoleTemplate::all();
foreach ($roles as $role) {
$perms = $role->permissions ?? [];
if (isset($perms[$module])) {
$perms[$module] = array_values(array_diff($perms[$module], $actions));
if (empty($perms[$module])) {
unset($perms[$module]);
}
$role->update(['permissions' => $perms]);
}
}
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Pages;
use App\Models\Project;
use Filament\Pages\Page;
use Filament\Tables\Table;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
class ProjectReport extends Page implements HasTable
{
use InteractsWithTable;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-chart-bar';
protected static ?string $navigationLabel = 'Báo cáo theo Dự án';
protected static ?string $title = 'Báo cáo Thống kê theo Dự án';
protected static string | \UnitEnum | null $navigationGroup = 'Quản lý Dòng tiền';
protected static ?int $navigationSort = 50;
protected string $view = 'filament.pages.project-report';
public function table(Table $table): Table
{
return $table
->query(
Project::query()
->select('projects.id', 'projects.name', 'projects.code')
->selectRaw('COUNT(DISTINCT products.id) as product_count')
->selectRaw('COUNT(DISTINCT CASE WHEN contracts.id IS NOT NULL THEN products.id END) as sold_product_count')
->selectRaw('COUNT(DISTINCT contracts.id) as contract_count')
->selectRaw('COALESCE(SUM(contracts.total_value), 0) as total_revenue')
->selectRaw('COALESCE(SUM(contracts.paid_amount), 0) as total_paid')
->selectRaw('COALESCE(SUM(contracts.remaining_amount), 0) as total_remaining')
->leftJoin('products', 'products.project_id', '=', 'projects.id')
->leftJoin('contracts', 'contracts.product_id', '=', 'products.id')
->groupBy('projects.id', 'projects.name', 'projects.code')
)
->columns([
TextColumn::make('name')
->label('Dự án')
->searchable()
->sortable(),
TextColumn::make('product_count')
->label('Tổng SP')
->alignCenter()
->sortable(),
TextColumn::make('sold_product_count')
->label('Đã bán')
->alignCenter()
->sortable()
->color('success'),
TextColumn::make('contract_count')
->label('Số HĐ')
->alignCenter()
->sortable(),
TextColumn::make('total_revenue')
->label('Tổng giá trị HĐ')
->money('VND')
->sortable()
->summarize(\Filament\Tables\Columns\Summarizers\Sum::make()->label('Tổng')->money('VND')),
TextColumn::make('total_paid')
->label('Đã thu')
->money('VND')
->sortable()
->color('success')
->summarize(\Filament\Tables\Columns\Summarizers\Sum::make()->label('Tổng')->money('VND')),
TextColumn::make('total_remaining')
->label('Công nợ phải thu')
->money('VND')
->sortable()
->color('danger')
->summarize(\Filament\Tables\Columns\Summarizers\Sum::make()->label('Tổng')->money('VND')),
])
->defaultSort('total_revenue', 'desc')
->paginated([10, 25, 50]);
}
}

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Appendices\Tables\AppendicesTable;
class AppendixResource extends Resource class AppendixResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Appendix::class; protected static ?string $model = Appendix::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -14,6 +14,7 @@ use App\Filament\Resources\Contracts\Tables\ContractsTable;
class ContractResource extends Resource class ContractResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Contract::class; protected static ?string $model = Contract::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -14,11 +14,16 @@ class CreateContract extends CreateRecord
{ {
$contract = $this->record; $contract = $this->record;
$template = null;
if ($contract->payment_template_id) { if ($contract->payment_template_id) {
$template = $contract->paymentTemplate; $template = $contract->paymentTemplate;
if ($template) { } elseif ($contract->sales_phase_id && $contract->salesPhase?->payment_template_id) {
ContractScheduleService::generateFromTemplate($contract, $template); $template = $contract->salesPhase->paymentTemplate;
} }
if ($template) {
ContractScheduleService::generateFromTemplate($contract, $template);
} }
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources\Contracts\Schemas;
use App\Models\Product; use App\Models\Product;
use App\Models\PaymentTemplate; use App\Models\PaymentTemplate;
use App\Models\SalesPhase;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
@@ -12,6 +13,7 @@ use Filament\Schemas\Components\Grid;
use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
@@ -41,9 +43,47 @@ class ContractForm
$set('total_value', $product->total_price); $set('total_value', $product->total_price);
$set('land_value', $product->qsdd_value); $set('land_value', $product->qsdd_value);
$set('foundation_value', $product->foundation_temp_value); $set('foundation_value', $product->foundation_temp_value);
$set('sales_phase_id', null);
} }
} }
}), }),
Select::make('sales_phase_id')
->label('Đợt mở bán')
->options(function (Get $get) {
$productId = $get('product_id');
if (! $productId) return [];
$product = Product::find($productId);
if (! $product) return [];
return SalesPhase::where('project_id', $product->project_id)
->whereIn('status', ['Chuẩn bị', 'Đang mở bán'])
->pluck('name', 'id');
})
->searchable()
->preload()
->live()
->helperText('Chọn đợt mở bán để áp dụng giá và chính sách của đợt')
->afterStateUpdated(function (Set $set, Get $get, $state) {
$productId = $get('product_id');
if ($state && $productId) {
$phaseProduct = \App\Models\SalesPhaseProduct::where('sales_phase_id', $state)
->where('product_id', $productId)
->first();
if ($phaseProduct) {
$set('land_value', $phaseProduct->land_value ?? $get('land_value'));
$set('foundation_value', $phaseProduct->foundation_value ?? $get('foundation_value'));
$total = (float) ($phaseProduct->land_value ?? $get('land_value')) + (float) ($phaseProduct->foundation_value ?? $get('foundation_value'));
if ($phaseProduct->sale_price > 0) {
$total = (float) $phaseProduct->sale_price;
}
$set('total_value', $total);
if ($phaseProduct->discount_details) {
$set('discount_details', $phaseProduct->discount_details);
}
}
}
}),
TextInput::make('contract_number') TextInput::make('contract_number')
->label('Số HĐMB') ->label('Số HĐMB')
->required() ->required()

View File

@@ -8,7 +8,10 @@ use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
class ContractsTable class ContractsTable
@@ -84,10 +87,13 @@ class ContractsTable
->queries( ->queries(
true: fn ($query) => $query->where('transfer_order', 0), true: fn ($query) => $query->where('transfer_order', 0),
false: fn ($query) => $query->where('transfer_order', '>', 0), false: fn ($query) => $query->where('transfer_order', '>', 0),
) ),
TrashedFilter::make(),
]) ])
->recordActions([ ->recordActions([
EditAction::make(), EditAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
Action::make('generateSchedule') Action::make('generateSchedule')
->label('Tạo lịch TT') ->label('Tạo lịch TT')
->icon('heroicon-o-calendar-days') ->icon('heroicon-o-calendar-days')

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Customers\Tables\CustomersTable;
class CustomerResource extends Resource class CustomerResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Customer::class; protected static ?string $model = Customer::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-users'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-users';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value;

View File

@@ -6,8 +6,8 @@ use App\Models\Customer;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TagsInput;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;

View File

@@ -5,8 +5,11 @@ namespace App\Filament\Resources\Customers\Tables;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
class CustomersTable class CustomersTable
@@ -64,9 +67,12 @@ class CustomersTable
'INDIVIDUAL' => 'Cá nhân', 'INDIVIDUAL' => 'Cá nhân',
'COMPANY' => 'Công ty', 'COMPANY' => 'Công ty',
]), ]),
TrashedFilter::make(),
]) ])
->recordActions([ ->recordActions([
EditAction::make(), EditAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\FormTemplates\Tables\FormTemplatesTable;
class FormTemplateResource extends Resource class FormTemplateResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = FormTemplate::class; protected static ?string $model = FormTemplate::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-duplicate'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-duplicate';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value;

View File

@@ -17,8 +17,9 @@ class FormTemplateForm
{ {
return $schema return $schema
->components([ ->components([
// BLOCK 1: Thông tin cơ bản // SECTION 1: Full width, field chia 3 cột
Section::make('Thông tin biểu mẫu') Section::make('Thông tin biểu mẫu')
->columnSpanFull()
->schema([ ->schema([
Grid::make(3) Grid::make(3)
->schema([ ->schema([
@@ -39,10 +40,7 @@ class FormTemplateForm
'App\Models\Customer' => 'Khách hàng', 'App\Models\Customer' => 'Khách hàng',
]) ])
->required(), ->required(),
]),
Grid::make(3)
->schema([
Select::make('paper_size') Select::make('paper_size')
->label('Khổ giấy') ->label('Khổ giấy')
->options([ ->options([
@@ -55,8 +53,9 @@ class FormTemplateForm
]), ]),
]), ]),
// BLOCK 2: Danh sách trường dữ liệu // SECTION 2: Full width, Repeater item chia 3 cột
Section::make('Danh sách trường dữ liệu (Merge Fields)') Section::make('Danh sách trường dữ liệu (Merge Fields)')
->columnSpanFull()
->schema([ ->schema([
Repeater::make('fields') Repeater::make('fields')
->relationship('fields') ->relationship('fields')
@@ -84,10 +83,7 @@ class FormTemplateForm
]) ])
->required() ->required()
->live(), ->live(),
]),
Grid::make(3)
->schema([
KeyValue::make('source_config') KeyValue::make('source_config')
->label('Cấu hình nguồn') ->label('Cấu hình nguồn')
->keyLabel('Tham số') ->keyLabel('Tham số')
@@ -117,13 +113,13 @@ class FormTemplateForm
->numeric() ->numeric()
->default(0) ->default(0)
->visible(fn ($get) => in_array($get('format'), ['number', 'currency', 'percent'])), ->visible(fn ($get) => in_array($get('format'), ['number', 'currency', 'percent'])),
]),
TextInput::make('display_order') TextInput::make('display_order')
->label('Thứ tự') ->label('Thứ tự')
->numeric() ->numeric()
->default(0) ->default(0)
->hidden(), ->hidden(),
]),
]) ])
->addActionLabel('Thêm trường dữ liệu') ->addActionLabel('Thêm trường dữ liệu')
->reorderable() ->reorderable()
@@ -133,15 +129,17 @@ class FormTemplateForm
->columnSpanFull(), ->columnSpanFull(),
]), ]),
// BLOCK 3: Nội dung mẫu in - FULL WIDTH, TO RỘNG // SECTION 3: Full width, RichEditor to
Section::make('Nội dung mẫu in') Section::make('Nội dung mẫu in')
->columnSpanFull()
->schema([ ->schema([
RichEditor::make('html_template') RichEditor::make('html_template')
->label('') ->label('')
->required() ->required()
->placeholder('Soạn thảo nội dung biểu mẫu...') ->placeholder('Soạn thảo nội dung biểu mẫu...')
->helperText('Chèn trường dữ liệu bằng cú pháp {{ma_truong}}. Ví dụ: Tên khách hàng: {{ten_khach_hang}}') ->helperText('Chèn trường dữ liệu bằng cú pháp {{ma_truong}}. Ví dụ: Tên khách hàng: {{ten_khach_hang}}')
->columnSpanFull(), ->columnSpanFull()
->extraInputAttributes(['style' => 'min-height: 500px;']),
]), ]),
]); ]);
} }

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\PaymentFines\Tables\PaymentFinesTable;
class PaymentFineResource extends Resource class PaymentFineResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = PaymentFine::class; protected static ?string $model = PaymentFine::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Payments\Tables\PaymentsTable;
class PaymentResource extends Resource class PaymentResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Payment::class; protected static ?string $model = Payment::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;

View File

@@ -149,6 +149,14 @@ class PaymentForm
]) ])
->default('Chuyển khoản') ->default('Chuyển khoản')
->required(), ->required(),
Select::make('collected_by')
->label('Ngườ thu')
->relationship('collector', 'name')
->searchable()
->preload()
->default(auth()->id())
->required(),
]), ]),
Section::make('Bổ sung') Section::make('Bổ sung')

View File

@@ -2,7 +2,13 @@
namespace App\Filament\Resources\Payments\Tables; namespace App\Filament\Resources\Payments\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
class PaymentsTable class PaymentsTable
@@ -73,6 +79,11 @@ class PaymentsTable
->money('VND') ->money('VND')
->placeholder('-') ->placeholder('-')
->color('danger'), ->color('danger'),
Tables\Columns\TextColumn::make('collector.name')
->label('Ngườ thu')
->placeholder('-')
->sortable(),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('method') Tables\Filters\SelectFilter::make('method')
@@ -94,6 +105,17 @@ class PaymentsTable
->when($data['from'], fn ($q) => $q->whereDate('paid_date', '>=', $data['from'])) ->when($data['from'], fn ($q) => $q->whereDate('paid_date', '>=', $data['from']))
->when($data['to'], fn ($q) => $q->whereDate('paid_date', '<=', $data['to'])); ->when($data['to'], fn ($q) => $q->whereDate('paid_date', '<=', $data['to']));
}), }),
TrashedFilter::make(),
])
->recordActions([
EditAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]) ])
->defaultSort('paid_date', 'desc'); ->defaultSort('paid_date', 'desc');
} }

View File

@@ -14,6 +14,7 @@ use App\Filament\Resources\Products\Schemas\ProductForm;
class ProductResource extends Resource class ProductResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Product::class; protected static ?string $model = Product::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Projects\Schemas\ProjectForm;
class ProjectResource extends Resource class ProjectResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Project::class; protected static ?string $model = Project::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::PROJECT->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::PROJECT->value;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\RoleTemplates;
use App\Filament\Resources\RoleTemplates\Pages;
use App\Models\RoleTemplate;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\RoleTemplates\Schemas\RoleTemplateForm;
use App\Filament\Resources\RoleTemplates\Tables\RoleTemplatesTable;
class RoleTemplateResource extends Resource
{
protected static ?string $model = RoleTemplate::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shield-check';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value;
protected static ?int $navigationSort = 90;
protected static ?string $modelLabel = 'Mẫu phân quyền';
protected static ?string $pluralModelLabel = 'Mẫu phân quyền';
public static function form(Schema $schema): Schema
{
return RoleTemplateForm::configure($schema);
}
public static function table(Table $table): Table
{
return RoleTemplatesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoleTemplates::route('/'),
'create' => Pages\CreateRoleTemplate::route('/create'),
'edit' => Pages\EditRoleTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Filament\Resources\RoleTemplates\Schemas;
use App\Models\PermissionModule;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class RoleTemplateForm
{
public static function configure(Schema $schema): Schema
{
$modules = PermissionModule::orderBy('label')->get();
$permissionComponents = [];
foreach ($modules as $module) {
$options = [];
foreach ($module->actions as $action) {
$label = match ($action) {
'view' => 'Xem',
'create' => 'Thêm',
'update' => 'Sửa',
'delete' => 'Xóa',
'restore' => 'Khôi phục',
'forceDelete' => 'Xóa vĩnh viễn',
'export' => 'Xuất Excel',
default => $action,
};
$options[$action] = $label;
}
$permissionComponents[] = CheckboxList::make("permissions.{$module->module}")
->label($module->label)
->options($options)
->columns(6)
->columnSpanFull();
}
return $schema
->components([
Section::make('Thông tin nhóm')
->columnSpanFull()
->columns(3)
->schema([
TextInput::make('name')
->label('Tên nhóm')
->required(),
TextInput::make('description')
->label('Mô tả'),
Toggle::make('is_active')
->label('Kích hoạt')
->default(true),
]),
Section::make('Phân quyền theo module')
->columnSpanFull()
->schema($permissionComponents),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Filament\Resources\RoleTemplates\Tables;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class RoleTemplatesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Tên nhóm')
->searchable()
->sortable(),
TextColumn::make('description')
->label('Mô tả')
->limit(50)
->toggleable(),
IconColumn::make('is_active')
->label('Kích hoạt')
->boolean()
->alignCenter(),
TextColumn::make('users_count')
->label('Số user')
->counts('users')
->alignCenter(),
TextColumn::make('created_at')
->label('Ngày tạo')
->dateTime('d/m/Y')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active')
->label('Trạng thái'),
])
->defaultSort('created_at', 'desc');
}
}

View File

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

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\SalesPhases\Pages;
use App\Filament\Resources\SalesPhases\SalesPhaseResource;
use Filament\Resources\Pages\EditRecord;
class EditSalesPhase extends EditRecord
{
protected static string $resource = SalesPhaseResource::class;
}

View File

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

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Resources\SalesPhases;
use App\Filament\Resources\SalesPhases\Pages;
use App\Models\SalesPhase;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\SalesPhases\Schemas\SalesPhaseForm;
use App\Filament\Resources\SalesPhases\Tables\SalesPhasesTable;
class SalesPhaseResource extends Resource
{
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = SalesPhase::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rocket-launch';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value;
protected static ?int $navigationSort = 2;
protected static ?string $modelLabel = 'Đợt mở bán';
protected static ?string $pluralModelLabel = 'Đợt mở bán';
public static function form(Schema $schema): Schema
{
return SalesPhaseForm::configure($schema);
}
public static function table(Table $table): Table
{
return SalesPhasesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListSalesPhases::route('/'),
'create' => Pages\CreateSalesPhase::route('/create'),
'edit' => Pages\EditSalesPhase::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Filament\Resources\SalesPhases\Schemas;
use App\Models\PaymentTemplate;
use App\Models\Product;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Repeater;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Set;
class SalesPhaseForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
// SECTION 1: Thông tin đợt mở bán
Section::make('Thông tin đợt mở bán')
->columnSpanFull()
->schema([
Grid::make(3)
->schema([
Select::make('project_id')
->label('Dự án')
->relationship('project', 'name')
->searchable()
->preload()
->required()
->live(),
TextInput::make('name')
->label('Tên đợt mở bán')
->placeholder('Đợt 1 - Mở bán tháng 5/2026')
->required(),
TextInput::make('code')
->label('Mã đợt')
->placeholder('MB1')
->required()
->unique(ignoreRecord: true),
]),
Grid::make(3)
->schema([
DatePicker::make('start_date')
->label('Ngày bắt đầu')
->required()
->default(now()),
DatePicker::make('end_date')
->label('Ngày kết thúc')
->nullable(),
Select::make('status')
->label('Trạng thái')
->options([
'Chuẩn bị' => 'Chuẩn bị',
'Đang mở bán' => 'Đang mở bán',
'Tạm dừng' => 'Tạm dừng',
'Đã đóng' => 'Đã đóng',
])
->default('Chuẩn bị')
->required(),
]),
Textarea::make('description')
->label('Mô tả')
->rows(3)
->columnSpanFull(),
]),
// SECTION 2: Chính sách & Lịch thanh toán
Section::make('Chính sách & Lịch thanh toán')
->columnSpanFull()
->schema([
Grid::make(2)
->schema([
Select::make('payment_template_id')
->label('Mẫu lịch thanh toán')
->relationship('paymentTemplate', 'name')
->searchable()
->preload()
->helperText('Áp dụng cho các hợp đồng trong đợt mở bán này')
->columnSpan(1),
KeyValue::make('discount_policy')
->label('Chính sách chiết khấu mặc định')
->keyLabel('Loại chiết khấu')
->valueLabel('Giá trị')
->helperText('Ví dụ: open_sale => 5%, wholesale => 3%')
->columnSpan(1),
]),
]),
// SECTION 3: Danh sách sản phẩm trong đợt
Section::make('Sản phẩm trong đợt mở bán')
->columnSpanFull()
->schema([
Repeater::make('phaseProducts')
->relationship('phaseProducts')
->schema([
Grid::make(4)
->schema([
Select::make('product_id')
->label('Sản phẩm')
->options(function (callable $get, $state) {
$projectId = $get('../../project_id');
if (! $projectId) return [];
return Product::where('project_id', $projectId)
->pluck('code', 'id');
})
->searchable()
->required(),
TextInput::make('sale_price')
->label('Giá bán đợt này')
->numeric()
->prefix('VND')
->placeholder('Để trống nếu lấy giá gốc'),
TextInput::make('land_value')
->label('Giá trị QSDĐ')
->numeric()
->prefix('VND'),
TextInput::make('foundation_value')
->label('Giá trị móng')
->numeric()
->prefix('VND'),
KeyValue::make('discount_details')
->label('Chiết khấu riêng')
->keyLabel('Loại')
->valueLabel('Giá trị')
->columnSpan(2),
Select::make('status')
->label('Trạng thái')
->options([
'Còn hàng' => 'Còn hàng',
'Đã giữ' => 'Đã giữ chỗ',
'Đã bán' => 'Đã bán',
'Khóa' => 'Khóa',
])
->default('Còn hàng')
->columnSpan(2),
]),
])
->addActionLabel('Thêm sản phẩm vào đợt')
->reorderable()
->defaultItems(0)
->collapsible(),
]),
]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Filament\Resources\SalesPhases\Tables;
use Filament\Tables;
use Filament\Tables\Table;
class SalesPhasesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('Tên đợt')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('code')
->label('Mã đợt')
->searchable()
->copyable(),
Tables\Columns\TextColumn::make('project.name')
->label('Dự án')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('start_date')
->label('Bắt đầu')
->date('d/m/Y')
->sortable(),
Tables\Columns\TextColumn::make('end_date')
->label('Kết thúc')
->date('d/m/Y')
->placeholder('Không giới hạn'),
Tables\Columns\TextColumn::make('status')
->label('Trạng thái')
->badge()
->color(fn (string $state): string => match ($state) {
'Đang mở bán' => 'success',
'Chuẩn bị' => 'warning',
'Tạm dừng' => 'danger',
'Đã đóng' => 'gray',
default => 'gray',
}),
Tables\Columns\TextColumn::make('products_count')
->label('Số SP')
->counts('products'),
Tables\Columns\TextColumn::make('paymentTemplate.name')
->label('Mẫu TT')
->placeholder('Chưa chọn'),
])
->filters([
Tables\Filters\SelectFilter::make('project_id')
->label('Dự án')
->relationship('project', 'name'),
Tables\Filters\SelectFilter::make('status')
->label('Trạng thái')
->options([
'Chuẩn bị' => 'Chuẩn bị',
'Đang mở bán' => 'Đang mở bán',
'Tạm dừng' => 'Tạm dừng',
'Đã đóng' => 'Đã đóng',
]),
])
->defaultSort('start_date', 'desc');
}
}

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Settlements\Tables\SettlementsTable;
class SettlementResource extends Resource class SettlementResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Settlement::class; protected static ?string $model = Settlement::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\Users\Schemas;
use App\Models\RoleTemplate;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class UserForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Grid::make(2)
->schema([
Section::make('Thông tin tài khoản')
->columnSpan(1)
->schema([
TextInput::make('name')
->label('Họ tên')
->required(),
TextInput::make('email')
->label('Email')
->email()
->required()
->unique(ignoreRecord: true),
]),
Section::make('Phân quyền')
->columnSpan(1)
->schema([
Select::make('role_template_id')
->label('Nhóm quyền (Mẫu)')
->relationship('roleTemplate', 'name')
->searchable()
->preload()
->placeholder('Không theo mẫu nào'),
TagsInput::make('extra_permissions')
->label('Thêm quyền (vượt cấp)')
->placeholder('ví dụ: contracts.export, payments.delete')
->helperText('Nhập quyền muốn thêm cho user này, bất chấp mẫu nhóm')
->separator(',')
->splitKeys([',', 'Enter']),
TagsInput::make('excluded_permissions')
->label('Bớt quyền (hạn chế)')
->placeholder('ví dụ: contracts.delete')
->helperText('Nhập quyền muốn tắt cho user này, bất chấp mẫu nhóm')
->separator(',')
->splitKeys([',', 'Enter']),
]),
]),
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Resources\Users\Tables;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Họ tên')
->searchable()
->sortable(),
TextColumn::make('email')
->label('Email')
->searchable()
->sortable(),
TextColumn::make('roleTemplate.name')
->label('Nhóm quyền')
->placeholder('Không có')
->badge()
->color('primary'),
TextColumn::make('created_at')
->label('Ngày tạo')
->dateTime('d/m/Y')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('role_template_id')
->label('Nhóm quyền')
->relationship('roleTemplate', 'name'),
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Users;
use App\Filament\Resources\Users\Pages;
use App\Models\User;
use App\Enums\NavigationGroup;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use App\Filament\Resources\Users\Schemas\UserForm;
use App\Filament\Resources\Users\Tables\UsersTable;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-user-group';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value;
protected static ?int $navigationSort = 100;
protected static ?string $modelLabel = 'Ngườ dùng';
protected static ?string $pluralModelLabel = 'Ngườ dùng';
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Filament\Widgets;
use Filament\Actions\Action;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class RecentNotifications extends BaseWidget
{
protected int | string | array $columnSpan = 'full';
protected static ?int $sort = 1;
public function table(Table $table): Table
{
return $table
->query(function () {
$user = auth()->user();
if (! $user) {
return \Illuminate\Notifications\DatabaseNotification::query()->whereRaw('1=0');
}
return $user->notifications()->whereNull('read_at')->latest()->getQuery();
})
->columns([
Tables\Columns\TextColumn::make('data.title')
->label('Tiêu đề')
->badge()
->color('warning'),
Tables\Columns\TextColumn::make('data.message')
->label('Nội dung')
->limit(100),
Tables\Columns\TextColumn::make('created_at')
->label('Thờ gian')
->dateTime('d/m/Y H:i')
->color('gray'),
])
->actions([
Action::make('markAsRead')
->label('Đánh dấu đã đọc')
->icon('heroicon-o-check-circle')
->color('success')
->action(function ($record) {
$record->markAsRead();
}),
])
->paginated([5, 10, 25])
->emptyStateHeading('Không có thông báo mới')
->emptyStateDescription('Bạn sẽ nhận được cảnh báo khi có đợt thanh toán sắp đến hạn.');
}
}

View File

@@ -6,13 +6,16 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\SoftDeletes;
class Contract extends Model class Contract extends Model
{ {
use HasUuids, HasFactory; use HasUuids, HasFactory, SoftDeletes;
protected $guarded = []; protected $guarded = [];
private static bool $calculating = false;
protected $casts = [ protected $casts = [
'metadata' => 'array', 'metadata' => 'array',
'discount_details' => 'array', 'discount_details' => 'array',
@@ -39,6 +42,11 @@ class Contract extends Model
return $this->belongsTo(PaymentTemplate::class); return $this->belongsTo(PaymentTemplate::class);
} }
public function salesPhase()
{
return $this->belongsTo(SalesPhase::class);
}
public function customers() public function customers()
{ {
return $this->belongsToMany(Customer::class, 'contract_customers') return $this->belongsToMany(Customer::class, 'contract_customers')
@@ -125,16 +133,26 @@ class Contract extends Model
}); });
static::saved(function ($contract) { static::saved(function ($contract) {
// Guard: tránh infinite loop khi lưu calculation_log
if (self::$calculating) return;
// Tự động tính toán và lưu snapshot sau khi lưu // Tự động tính toán và lưu snapshot sau khi lưu
if ($contract->land_value || $contract->foundation_value) { if ($contract->land_value || $contract->foundation_value) {
$result = \App\Services\Calculation\PriceCalculationService::calculateForContract($contract); self::$calculating = true;
$contract->calculation_log = [
'steps' => $result->getSteps(), try {
'final_values' => $result->getValues(), $result = \App\Services\Calculation\PriceCalculationService::calculateForContract($contract);
'price_sheet' => $result->toPriceSheet(), $contract->updateQuietly([
'calculated_at' => now()->toDateTimeString(), 'calculation_log' => [
]; 'steps' => $result->getSteps(),
$contract->saveQuietly(); 'final_values' => $result->getValues(),
'price_sheet' => $result->toPriceSheet(),
'calculated_at' => now()->toDateTimeString(),
],
]);
} finally {
self::$calculating = false;
}
} }
}); });
} }

View File

@@ -7,10 +7,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model class Customer extends Model
{ {
use HasUuids, HasFactory; use HasUuids, HasFactory, SoftDeletes;
protected $guarded = []; protected $guarded = [];

View File

@@ -5,10 +5,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Payment extends Model class Payment extends Model
{ {
use HasUuids, HasFactory; use HasUuids, HasFactory, SoftDeletes;
protected $guarded = []; protected $guarded = [];
@@ -27,4 +28,9 @@ class Payment extends Model
{ {
return $this->belongsTo(PaymentScheduleItem::class, 'schedule_item_id'); return $this->belongsTo(PaymentScheduleItem::class, 'schedule_item_id');
} }
public function collector()
{
return $this->belongsTo(User::class, 'collected_by');
}
} }

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PermissionModule extends Model
{
use HasUuids, HasFactory;
protected $fillable = ['module', 'label', 'actions'];
protected $casts = [
'actions' => 'array',
];
}

View File

@@ -42,4 +42,25 @@ class Product extends Model
{ {
return $this->hasMany(Appendix::class); return $this->hasMany(Appendix::class);
} }
public function salesPhases()
{
return $this->belongsToMany(SalesPhase::class, 'sales_phase_products')
->using(SalesPhaseProduct::class)
->withPivot('id', 'sale_price', 'land_value', 'foundation_value', 'discount_details', 'status')
->withTimestamps();
}
public function activeSalesPhase()
{
return $this->salesPhases()
->wherePivot('status', 'Còn hàng')
->where('sales_phases.status', 'Đang mở bán')
->whereDate('sales_phases.start_date', '<=', now())
->where(function ($q) {
$q->whereNull('sales_phases.end_date')
->orWhereDate('sales_phases.end_date', '>=', now());
})
->first();
}
} }

View File

@@ -26,4 +26,9 @@ class Project extends Model
{ {
return $this->hasMany(PaymentTemplate::class); return $this->hasMany(PaymentTemplate::class);
} }
public function salesPhases()
{
return $this->hasMany(SalesPhase::class)->orderBy('start_date', 'desc');
}
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RoleTemplate extends Model
{
use HasUuids, HasFactory;
protected $fillable = ['name', 'description', 'permissions', 'is_active'];
protected $casts = [
'permissions' => 'array',
'is_active' => 'boolean',
];
public function users()
{
return $this->hasMany(User::class);
}
}

43
app/Models/SalesPhase.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SalesPhase extends Model
{
use HasUuids, HasFactory;
protected $guarded = [];
protected $casts = [
'discount_policy' => 'array',
'start_date' => 'date',
'end_date' => 'date',
];
public function project()
{
return $this->belongsTo(Project::class);
}
public function paymentTemplate()
{
return $this->belongsTo(PaymentTemplate::class);
}
public function phaseProducts()
{
return $this->hasMany(SalesPhaseProduct::class);
}
public function products()
{
return $this->belongsToMany(Product::class, 'sales_phase_products')
->using(SalesPhaseProduct::class)
->withPivot('id', 'sale_price', 'land_value', 'foundation_value', 'discount_details', 'status')
->withTimestamps();
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\Pivot;
class SalesPhaseProduct extends Pivot
{
use HasUuids, HasFactory;
protected $table = 'sales_phase_products';
protected $guarded = [];
protected $casts = [
'sale_price' => 'decimal:2',
'land_value' => 'decimal:2',
'foundation_value' => 'decimal:2',
'discount_details' => 'array',
];
public function salesPhase()
{
return $this->belongsTo(SalesPhase::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -29,9 +29,83 @@ class User extends Authenticatable implements FilamentUser
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'extra_permissions' => 'array',
'excluded_permissions' => 'array',
]; ];
} }
public function roleTemplate()
{
return $this->belongsTo(RoleTemplate::class);
}
/**
* Tính toán effective permissions từ role template + extra - excluded.
* Cache trong session (1 lần/login).
*/
public function getEffectivePermissions(): array
{
$cacheKey = "user.{$this->id}.permissions";
if (session()->has($cacheKey)) {
return session()->get($cacheKey);
}
$permissions = $this->calculateEffectivePermissions();
session()->put($cacheKey, $permissions);
return $permissions;
}
public function hasEffectivePermission(string $permission): bool
{
return in_array($permission, $this->getEffectivePermissions());
}
public function clearPermissionCache(): void
{
session()->forget("user.{$this->id}.permissions");
}
protected function calculateEffectivePermissions(): array
{
$templatePerms = [];
if ($this->roleTemplate) {
$templatePerms = $this->roleTemplate->permissions ?? [];
}
// Flatten template permissions từ {"contracts":["view","create"]} thành ["contracts.view","contracts.create"]
$templateFlat = [];
foreach ($templatePerms as $module => $actions) {
foreach ($actions as $action) {
$templateFlat[] = "{$module}.{$action}";
}
}
$extra = $this->extra_permissions ?? [];
$excluded = $this->excluded_permissions ?? [];
return array_values(array_diff(
array_unique(array_merge($templateFlat, $extra)),
$excluded
));
}
/**
* Override can() để tích hợp với Laravel Authorization.
* Nếu ability dạng "contracts.view" dùng effective permissions.
* Ngược lại fallback về parent.
*/
public function can($abilities, $arguments = []): bool
{
if (is_string($abilities) && str_contains($abilities, '.')) {
return $this->hasEffectivePermission($abilities);
}
return parent::can($abilities, $arguments);
}
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return true; return true;

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Notifications;
use App\Models\PaymentScheduleItem;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class PaymentDueNotification extends Notification
{
use Queueable;
public function __construct(
public PaymentScheduleItem $scheduleItem
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$contract = $this->scheduleItem->schedule?->contract;
$remaining = (float) $this->scheduleItem->remaining_amount;
return [
'title' => 'Cảnh báo đợt thanh toán sắp đến hạn',
'message' => sprintf(
'HĐ %s - Đợt %d (%s) sẽ đến hạn vào %s. Còn thiếu: %s VNĐ.',
$contract?->contract_number ?? 'N/A',
$this->scheduleItem->installment_no,
$this->scheduleItem->type?->getLabel() ?? 'N/A',
$this->scheduleItem->due_date?->format('d/m/Y') ?? 'N/A',
number_format($remaining)
),
'contract_id' => $contract?->id,
'schedule_item_id' => $this->scheduleItem->id,
'due_date' => $this->scheduleItem->due_date?->toDateString(),
'remaining_amount' => $remaining,
];
}
}

View File

@@ -12,6 +12,7 @@ use Filament\PanelProvider;
use Filament\Support\Colors\Color; use Filament\Support\Colors\Color;
use App\Filament\Widgets\ContractStatsOverview; use App\Filament\Widgets\ContractStatsOverview;
use App\Filament\Widgets\UpcomingPaymentsTable; use App\Filament\Widgets\UpcomingPaymentsTable;
use App\Filament\Widgets\RecentNotifications;
use Filament\Widgets\AccountWidget; use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget; use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -41,6 +42,7 @@ class AdminPanelProvider extends PanelProvider
]) ])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([ ->widgets([
RecentNotifications::class,
ContractStatsOverview::class, ContractStatsOverview::class,
UpcomingPaymentsTable::class, UpcomingPaymentsTable::class,
AccountWidget::class, AccountWidget::class,

View File

@@ -7,63 +7,67 @@ use App\Models\PaymentSchedule;
use App\Models\PaymentScheduleItem; use App\Models\PaymentScheduleItem;
use App\Models\PaymentTemplate; use App\Models\PaymentTemplate;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class ContractScheduleService class ContractScheduleService
{ {
/** /**
* Tạo lịch thanh toán cho hợp đồng dựa trên mẫu. * 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 , sẽ xóa tạo lại. * Nếu đã tồn tại lịch , sẽ xóa tạo lại.
* Toàn bộ quá trình được bọc trong DB Transaction để đảm bảo tính toàn vẹn.
*/ */
public static function generateFromTemplate(Contract $contract, ?PaymentTemplate $template = null): PaymentSchedule public static function generateFromTemplate(Contract $contract, ?PaymentTemplate $template = null): PaymentSchedule
{ {
if (! $template) { return DB::transaction(function () use ($contract, $template) {
// Ưu tiên template của dự án if (! $template) {
$template = $contract->product?->project?->paymentTemplate; // Ư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([ if (! $template) {
'schedule_id' => $schedule->id, throw new \InvalidArgumentException('Không tìm thấy mẫu thanh toán cho hợp đồng này.');
'installment_no' => $item->installment_no, }
'type' => $item->type,
'percentage' => $item->percentage, // Xóa lịch cũ nếu có
'amount' => $contract->total_value * ($item->percentage / 100), if ($contract->paymentSchedule) {
'due_date' => $dueDate, $contract->paymentSchedule->items()->delete();
$contract->paymentSchedule->delete();
}
$schedule = PaymentSchedule::create([
'contract_id' => $contract->id,
'template_id' => $template->id,
]); ]);
if ($dueDate) { $items = $template->items()->orderBy('installment_no')->get();
$lastDueDate = $dueDate; $lastDueDate = Carbon::parse($contract->signing_date);
}
}
return $schedule; 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;
});
} }
} }

View File

@@ -72,35 +72,116 @@ class MailMergeService
$evalExpression = $expression; $evalExpression = $expression;
foreach ($values as $key => $value) { foreach ($values as $key => $value) {
if (is_numeric($value)) { if (is_numeric($value)) {
$evalExpression = str_replace($key, (float) $value, $evalExpression); $evalExpression = str_replace($key, $value, $evalExpression);
} }
} }
// Chỉ cho phép số và các phép toán cơ bản // Chỉ cho phép số, dấu chấm, dấu phẩy và các phép toán cơ bản
$evalExpression = str_replace(',', '.', $evalExpression);
$evalExpression = preg_replace('/[^0-9.\+\-\*\/\(\)\s]/', '', $evalExpression); $evalExpression = preg_replace('/[^0-9.\+\-\*\/\(\)\s]/', '', $evalExpression);
$evalExpression = str_replace(' ', '', $evalExpression);
if (empty($evalExpression)) return 0; if (empty($evalExpression)) return 0;
try { try {
// Eval an toàn với chỉ phép toán $result = self::safeCalculate($evalExpression);
$result = self::safeEval($evalExpression);
return (float) $result; return (float) $result;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return 0; return 0;
} }
} }
protected static function safeEval(string $expression): float protected static function safeCalculate(string $expression): float
{ {
// Loại bỏ các hàm nguy hiểm, chỉ giữ phép toán // Tokenize: tách số và operators
$expression = preg_replace('/[^0-9.\+\-\*\/\(\)\s]/', '', $expression); $tokens = [];
$number = '';
if (empty($expression) || preg_match('/[a-zA-Z]/', $expression)) { for ($i = 0; $i < strlen($expression); $i++) {
$char = $expression[$i];
if (ctype_digit($char) || $char === '.') {
$number .= $char;
} else {
if ($number !== '') {
$tokens[] = (float) $number;
$number = '';
}
$tokens[] = $char;
}
}
if ($number !== '') {
$tokens[] = (float) $number;
}
// Shunting yard algorithm: infix → postfix
$output = [];
$stack = [];
$precedence = ['+' => 1, '-' => 1, '*' => 2, '/' => 2];
foreach ($tokens as $token) {
if (is_numeric($token)) {
$output[] = $token;
} elseif ($token === '(') {
$stack[] = $token;
} elseif ($token === ')') {
while (!empty($stack) && end($stack) !== '(') {
$output[] = array_pop($stack);
}
array_pop($stack); // pop '('
} else {
// Operator
while (!empty($stack) && end($stack) !== '(' &&
isset($precedence[end($stack)]) &&
$precedence[end($stack)] >= $precedence[$token]) {
$output[] = array_pop($stack);
}
$stack[] = $token;
}
}
while (!empty($stack)) {
$output[] = array_pop($stack);
}
// Evaluate postfix
$evalStack = [];
foreach ($output as $token) {
if (is_numeric($token)) {
$evalStack[] = $token;
} else {
$b = array_pop($evalStack);
$a = array_pop($evalStack);
if ($a === null || $b === null) {
throw new \InvalidArgumentException('Invalid expression');
}
switch ($token) {
case '+':
$evalStack[] = bcadd((string) $a, (string) $b, 10);
break;
case '-':
$evalStack[] = bcsub((string) $a, (string) $b, 10);
break;
case '*':
$evalStack[] = bcmul((string) $a, (string) $b, 10);
break;
case '/':
if ((float) $b == 0) throw new \InvalidArgumentException('Division by zero');
$evalStack[] = bcdiv((string) $a, (string) $b, 10);
break;
}
}
}
if (count($evalStack) !== 1) {
throw new \InvalidArgumentException('Invalid expression'); throw new \InvalidArgumentException('Invalid expression');
} }
// Dùng bc math nếu có, hoặc eval đơn giản return (float) $evalStack[0];
return (float) eval('return ' . $expression . ';');
} }
/** /**

View File

@@ -0,0 +1,54 @@
<?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::create('sales_phases', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('project_id')->constrained('projects')->cascadeOnDelete();
$table->string('name'); // Tên đợt: Mở bán đợt 1, Đợt 2...
$table->string('code')->unique(); // Mã đợt: MB1, MB2...
$table->text('description')->nullable();
$table->date('start_date'); // Ngày bắt đầu mở bán
$table->date('end_date')->nullable(); // Ngày kết thúc
$table->string('status')->default('Đang mở bán'); // Đang mở bán, Đã đóng, Chuẩn bị
// Chính sách chiết khấu mặc định cho đợt
$table->jsonb('discount_policy')->nullable();
// Ví dụ: {"open_sale": "5%", "wholesale": "3%", "full_payment": "2%"}
// Mẫu thanh toán mặc định cho đợt
$table->foreignUuid('payment_template_id')->nullable()->constrained('payment_templates')->nullOnDelete();
$table->timestamps();
});
Schema::create('sales_phase_products', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('sales_phase_id')->constrained('sales_phases')->cascadeOnDelete();
$table->foreignUuid('product_id')->constrained('products')->cascadeOnDelete();
// Giá bán cụ thể trong đợt này (ghi đè giá gốc nếu có)
$table->decimal('sale_price', 15, 2)->nullable();
$table->decimal('land_value', 15, 2)->nullable();
$table->decimal('foundation_value', 15, 2)->nullable();
// Chiết khấu riêng cho sản phẩm này trong đợt
$table->jsonb('discount_details')->nullable();
// Trạng thái sản phẩm trong đợt
$table->string('status')->default('Còn hàng'); // Còn hàng, Đã giữ, Đã bán, Khóa
$table->unique(['sales_phase_id', 'product_id']);
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('sales_phase_products');
Schema::dropIfExists('sales_phases');
}
};

View File

@@ -0,0 +1,19 @@
<?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) {
$table->foreignUuid('sales_phase_id')->nullable()->constrained('sales_phases')->nullOnDelete();
});
}
public function down(): void {
Schema::table('contracts', function (Blueprint $table) {
$table->dropConstrainedForeignId('sales_phase_id');
});
}
};

View File

@@ -0,0 +1,25 @@
<?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::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -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) {
$table->softDeletes();
});
Schema::table('payments', function (Blueprint $table) {
$table->softDeletes();
});
Schema::table('customers', function (Blueprint $table) {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('contracts', function (Blueprint $table) {
$table->dropSoftDeletes();
});
Schema::table('payments', function (Blueprint $table) {
$table->dropSoftDeletes();
});
Schema::table('customers', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,23 @@
<?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('payments', function (Blueprint $table) {
$table->foreignId('collected_by')->nullable()->constrained('users')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('payments', function (Blueprint $table) {
$table->dropForeign(['collected_by']);
$table->dropColumn('collected_by');
});
}
};

View File

@@ -0,0 +1,48 @@
<?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
{
// Bảng đăng ký các module có thể phân quyền (auto từ Resource)
Schema::create('permission_modules', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('module')->unique(); // contracts, payments, customers...
$table->string('label'); // Hợp đồng, Thu tiền...
$table->jsonb('actions'); // ["view","create","update","delete","restore","forceDelete","export"]
$table->timestamps();
});
// Bảng mẫu nhóm (Role Template)
Schema::create('role_templates', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name'); // Sales, Kế toán, Admin...
$table->text('description')->nullable();
$table->jsonb('permissions')->default('{}'); // {"contracts":["view","create"], "payments":["view"]}
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// Sửa users: thêm role_template_id, extra_permissions, excluded_permissions
Schema::table('users', function (Blueprint $table) {
$table->foreignUuid('role_template_id')->nullable()->constrained('role_templates')->nullOnDelete();
$table->jsonb('extra_permissions')->default('[]');
$table->jsonb('excluded_permissions')->default('[]');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['role_template_id']);
$table->dropColumn(['role_template_id', 'extra_permissions', 'excluded_permissions']);
});
Schema::dropIfExists('role_templates');
Schema::dropIfExists('permission_modules');
}
};

View File

@@ -0,0 +1,10 @@
<x-filament-panels::page>
<div style="margin-bottom: 1rem;">
<p style="color: #6b7280; font-size: 0.875rem;">
Báo cáo tổng hợp giá trị hợp đồng, thanh toán công nợ theo từng dự án.
Dữ liệu được tính toán trực tiếp từ hệ thống.
</p>
</div>
{{ $this->table }}
</x-filament-panels::page>

View File

@@ -1,29 +0,0 @@
name: chanphuong
email: admin@phuongtc.com
pass: 1Qazxsw2@!321
git add .
git commit -m "Thêm tính năng X"
git push
✦ Bước 2: Hướng dẫn bạn di chuyển
Bây giờ bạn chỉ cần thực hiện các thao tác sau để đảm bảo sự liền mạch:
1. Commit & Push: Hãy commit toàn bộ code hiện tại (bao gồm cả file GEMINI.md tôi vừa tạo) lên kho lưu trữ Git của bạn
(GitHub/GitLab/Bitbucket).
2. Môi trường mới: Trên máy tính mới, sau khi git clone dự án về:
* Hãy đảm bảo đã cài đặt đầy đủ môi trường (PHP, PostgreSQL...).
* Chạy composer install.
* Cấu hình file .env chính xác cho Database.
* Chạy lại php artisan migrate:fresh --seed để có bộ dữ liệu mẫu mà chúng ta đã thống nhất.
3. Kích hoạt Gemini: Khi bạn mở terminal tại thư mục dự án trên máy mới và gọi Gemini CLI, tôi sẽ tự động đọc file
GEMINI.md.
4. Kiểm tra: Bạn có thể hỏi tôi câu này để kiểm tra xem tôi có "nhận ra" dự án không:
> "Dự án này đang dùng kiến trúc Filament nào và logic chuyển nhượng bất động sản được quy định ra sao?"
Nếu tôi trả lời đúng về Filament v5.5 Schemas và các giá trị của transfer_order, nghĩa là cuộc "di cư tri thức" đã thành
công rực rỡ.
Chúc bạn có một hành trình làm việc thuận lợi trên máy tính mới! Tôi luôn sẵn sàng đồng hành cùng bạn.