Fix 3 loi nghiem trong: eval() -> safe parser, Contract::saved() infinite loop, DB Transaction for schedule generation
This commit is contained in:
@@ -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
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 24/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()`** | `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()`** | 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** | `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** | Tất cả model dùng `Model::delete()` cứng | Xóa nhầm HĐ/Thu tiền → mất vĩnh viễn, không 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ười thu** | `Payment` chỉ có `contract_id`, không có `collected_by` | Không biết ai thu tiền, khó trách nhiệm |
|
||||||
|
| 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ả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** | Không có | Mở bán Phase 1, 2, 3 với giá/chính sách khác nhau | 🔴 Thiếu |
|
||||||
|
| **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 (1 tuần - Ưu tiên CAO NHẤT)
|
||||||
|
1. **Thay thế `eval()`** trong MailMergeService bằng `symfony/expression-language` hoặc thư viện math an toàn
|
||||||
|
2. **Thêm `DB::transaction`** cho tất cả service tạo HĐ, tạo lịch, ghi nhận thu tiền
|
||||||
|
3. **Thêm Soft Delete** cho Contract, Payment, Customer + model `DeletedBy` để audit
|
||||||
|
4. **Thêm `collected_by`** vào bảng `payments` + hiển thị người thu trong Form/Table
|
||||||
|
5. **Fix `Contract::saved()`** - tránh loop, dùng cách lưu calculation_log an toàn hơn
|
||||||
|
|
||||||
|
### 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ụ (1 tháng)
|
||||||
|
10. **CRM Pipeline** - Lead → Opportunity → Contract với các stage tùy chỉnh
|
||||||
|
11. **Quản lý đợt mở bán** - Mỗi đợt có giá bán, chính sách chiết khấu riêng
|
||||||
|
12. **Chính sách bán hàng động** - Chiết khấu theo thời đ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.
|
||||||
@@ -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,8 @@ class Contract extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
private static bool $calculating = false;
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
'discount_details' => 'array',
|
'discount_details' => 'array',
|
||||||
@@ -125,16 +127,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) {
|
||||||
|
self::$calculating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
$result = \App\Services\Calculation\PriceCalculationService::calculateForContract($contract);
|
$result = \App\Services\Calculation\PriceCalculationService::calculateForContract($contract);
|
||||||
$contract->calculation_log = [
|
$contract->updateQuietly([
|
||||||
|
'calculation_log' => [
|
||||||
'steps' => $result->getSteps(),
|
'steps' => $result->getSteps(),
|
||||||
'final_values' => $result->getValues(),
|
'final_values' => $result->getValues(),
|
||||||
'price_sheet' => $result->toPriceSheet(),
|
'price_sheet' => $result->toPriceSheet(),
|
||||||
'calculated_at' => now()->toDateTimeString(),
|
'calculated_at' => now()->toDateTimeString(),
|
||||||
];
|
],
|
||||||
$contract->saveQuietly();
|
]);
|
||||||
|
} finally {
|
||||||
|
self::$calculating = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ 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
|
||||||
{
|
{
|
||||||
|
return DB::transaction(function () use ($contract, $template) {
|
||||||
if (! $template) {
|
if (! $template) {
|
||||||
// Ưu tiên template của dự án
|
// Ưu tiên template của dự án
|
||||||
$template = $contract->product?->project?->paymentTemplate;
|
$template = $contract->product?->project?->paymentTemplate;
|
||||||
@@ -65,5 +68,6 @@ class ContractScheduleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $schedule;
|
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');
|
throw new \InvalidArgumentException('Invalid expression');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dùng bc math nếu có, hoặc eval đơn giản
|
switch ($token) {
|
||||||
return (float) eval('return ' . $expression . ';');
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) $evalStack[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user