Compare commits
10 Commits
49aa20a634
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2366181476 | |||
| 5d9088d222 | |||
| da89a296c1 | |||
| 1c7d77a050 | |||
| 40b75fcf75 | |||
| d2df9edd69 | |||
| c7b1d08afa | |||
| 78c22690eb | |||
| 0712046f4b | |||
| e229da5e8c |
107
AGENTS.md
107
AGENTS.md
@@ -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
121
ASSESSMENT.md
Normal 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
60
FILAMENT_LAYOUT_NOTES.md
Normal 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.
|
||||||
107
NEXT_SESSION.md
107
NEXT_SESSION.md
@@ -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
|
|
||||||
|
|
||||||
Có **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
566
WORKFLOW.md
Normal 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ụ.*
|
||||||
151
app/Console/Commands/ExportDebtReport.php
Normal file
151
app/Console/Commands/ExportDebtReport.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Console/Commands/SendPaymentDueNotifications.php
Normal file
76
app/Console/Commands/SendPaymentDueNotifications.php
Normal 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 kê, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/Console/Commands/SyncPermissions.php
Normal file
180
app/Console/Commands/SyncPermissions.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Filament/Pages/ProjectReport.php
Normal file
84
app/Filament/Pages/ProjectReport.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;']),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
11
app/Filament/Resources/SalesPhases/Pages/EditSalesPhase.php
Normal file
11
app/Filament/Resources/SalesPhases/Pages/EditSalesPhase.php
Normal 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;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/SalesPhases/Pages/ListSalesPhases.php
Normal file
19
app/Filament/Resources/SalesPhases/Pages/ListSalesPhases.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Filament/Resources/SalesPhases/SalesPhaseResource.php
Normal file
43
app/Filament/Resources/SalesPhases/SalesPhaseResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/Filament/Resources/SalesPhases/Schemas/SalesPhaseForm.php
Normal file
163
app/Filament/Resources/SalesPhases/Schemas/SalesPhaseForm.php
Normal 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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
11
app/Filament/Resources/Users/Pages/CreateUser.php
Normal file
11
app/Filament/Resources/Users/Pages/CreateUser.php
Normal 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;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Users/Pages/EditUser.php
Normal file
19
app/Filament/Resources/Users/Pages/EditUser.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Users/Pages/ListUsers.php
Normal file
19
app/Filament/Resources/Users/Pages/ListUsers.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Filament/Resources/Users/Schemas/UserForm.php
Normal file
61
app/Filament/Resources/Users/Schemas/UserForm.php
Normal 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']),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Filament/Resources/Users/Tables/UsersTable.php
Normal file
43
app/Filament/Resources/Users/Tables/UsersTable.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Filament/Resources/Users/UserResource.php
Normal file
42
app/Filament/Resources/Users/UserResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Filament/Widgets/RecentNotifications.php
Normal file
53
app/Filament/Widgets/RecentNotifications.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Models/PermissionModule.php
Normal file
18
app/Models/PermissionModule.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
app/Models/RoleTemplate.php
Normal file
24
app/Models/RoleTemplate.php
Normal 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
43
app/Models/SalesPhase.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Models/SalesPhaseProduct.php
Normal file
33
app/Models/SalesPhaseProduct.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 là 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;
|
||||||
|
|||||||
43
app/Notifications/PaymentDueNotification.php
Normal file
43
app/Notifications/PaymentDueNotification.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 cũ, sẽ xóa và tạo lại.
|
* Nếu đã tồn tại lịch cũ, sẽ xóa và 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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 . ';');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
10
resources/views/filament/pages/project-report.blade.php
Normal file
10
resources/views/filament/pages/project-report.blade.php
Normal 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 và 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>
|
||||||
29
taikhoan.txt
29
taikhoan.txt
@@ -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.
|
|
||||||
Reference in New Issue
Block a user