From e229da5e8c2fc49d473e535468b4903c1b71344c Mon Sep 17 00:00:00 2001 From: phuongtc Date: Tue, 28 Apr 2026 08:04:30 +0000 Subject: [PATCH] Fix 3 loi nghiem trong: eval() -> safe parser, Contract::saved() infinite loop, DB Transaction for schedule generation --- AGENTS.md | 7 + ASSESSMENT.md | 121 ++++++++++++++++++ FILAMENT_LAYOUT_NOTES.md | 60 +++++++++ .../Schemas/FormTemplateForm.php | 30 ++--- app/Models/Contract.php | 28 ++-- app/Services/ContractScheduleService.php | 92 ++++++------- app/Services/Forms/MailMergeService.php | 101 +++++++++++++-- 7 files changed, 361 insertions(+), 78 deletions(-) create mode 100644 ASSESSMENT.md create mode 100644 FILAMENT_LAYOUT_NOTES.md diff --git a/AGENTS.md b/AGENTS.md index c4c907b..462f81b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,13 @@ 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. +### ⚠️ 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 diff --git a/ASSESSMENT.md b/ASSESSMENT.md new file mode 100644 index 0000000..ce8fd94 --- /dev/null +++ b/ASSESSMENT.md @@ -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ỳ.* diff --git a/FILAMENT_LAYOUT_NOTES.md b/FILAMENT_LAYOUT_NOTES.md new file mode 100644 index 0000000..baa12fa --- /dev/null +++ b/FILAMENT_LAYOUT_NOTES.md @@ -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. diff --git a/app/Filament/Resources/FormTemplates/Schemas/FormTemplateForm.php b/app/Filament/Resources/FormTemplates/Schemas/FormTemplateForm.php index 6edfd5d..4773d23 100644 --- a/app/Filament/Resources/FormTemplates/Schemas/FormTemplateForm.php +++ b/app/Filament/Resources/FormTemplates/Schemas/FormTemplateForm.php @@ -17,8 +17,9 @@ class FormTemplateForm { return $schema ->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') + ->columnSpanFull() ->schema([ Grid::make(3) ->schema([ @@ -39,10 +40,7 @@ class FormTemplateForm 'App\Models\Customer' => 'Khách hàng', ]) ->required(), - ]), - Grid::make(3) - ->schema([ Select::make('paper_size') ->label('Khổ giấy') ->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)') + ->columnSpanFull() ->schema([ Repeater::make('fields') ->relationship('fields') @@ -84,10 +83,7 @@ class FormTemplateForm ]) ->required() ->live(), - ]), - Grid::make(3) - ->schema([ KeyValue::make('source_config') ->label('Cấu hình nguồn') ->keyLabel('Tham số') @@ -117,13 +113,13 @@ class FormTemplateForm ->numeric() ->default(0) ->visible(fn ($get) => in_array($get('format'), ['number', 'currency', 'percent'])), - ]), - TextInput::make('display_order') - ->label('Thứ tự') - ->numeric() - ->default(0) - ->hidden(), + TextInput::make('display_order') + ->label('Thứ tự') + ->numeric() + ->default(0) + ->hidden(), + ]), ]) ->addActionLabel('Thêm trường dữ liệu') ->reorderable() @@ -133,15 +129,17 @@ class FormTemplateForm ->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') + ->columnSpanFull() ->schema([ RichEditor::make('html_template') ->label('') ->required() ->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}}') - ->columnSpanFull(), + ->columnSpanFull() + ->extraInputAttributes(['style' => 'min-height: 500px;']), ]), ]); } diff --git a/app/Models/Contract.php b/app/Models/Contract.php index d3ddb36..d797b13 100644 --- a/app/Models/Contract.php +++ b/app/Models/Contract.php @@ -13,6 +13,8 @@ class Contract extends Model protected $guarded = []; + private static bool $calculating = false; + protected $casts = [ 'metadata' => 'array', 'discount_details' => 'array', @@ -125,16 +127,26 @@ class Contract extends Model }); 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 if ($contract->land_value || $contract->foundation_value) { - $result = \App\Services\Calculation\PriceCalculationService::calculateForContract($contract); - $contract->calculation_log = [ - 'steps' => $result->getSteps(), - 'final_values' => $result->getValues(), - 'price_sheet' => $result->toPriceSheet(), - 'calculated_at' => now()->toDateTimeString(), - ]; - $contract->saveQuietly(); + self::$calculating = true; + + try { + $result = \App\Services\Calculation\PriceCalculationService::calculateForContract($contract); + $contract->updateQuietly([ + 'calculation_log' => [ + 'steps' => $result->getSteps(), + 'final_values' => $result->getValues(), + 'price_sheet' => $result->toPriceSheet(), + 'calculated_at' => now()->toDateTimeString(), + ], + ]); + } finally { + self::$calculating = false; + } } }); } diff --git a/app/Services/ContractScheduleService.php b/app/Services/ContractScheduleService.php index e7ce8a8..99e966d 100644 --- a/app/Services/ContractScheduleService.php +++ b/app/Services/ContractScheduleService.php @@ -7,63 +7,67 @@ use App\Models\PaymentSchedule; use App\Models\PaymentScheduleItem; use App\Models\PaymentTemplate; use Carbon\Carbon; +use Illuminate\Support\Facades\DB; class ContractScheduleService { /** * Tạo lịch thanh toán cho hợp đồng dựa trên mẫu. * Nếu đã tồn tại lịch cũ, sẽ xóa và tạo lại. + * 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 { - if (! $template) { - // Ưu tiên template của dự án - $template = $contract->product?->project?->paymentTemplate; - } - - if (! $template) { - throw new \InvalidArgumentException('Không tìm thấy mẫu thanh toán cho hợp đồng này.'); - } - - // Xóa lịch cũ nếu có - if ($contract->paymentSchedule) { - $contract->paymentSchedule->items()->delete(); - $contract->paymentSchedule->delete(); - } - - $schedule = PaymentSchedule::create([ - 'contract_id' => $contract->id, - 'template_id' => $template->id, - ]); - - $items = $template->items()->orderBy('installment_no')->get(); - $lastDueDate = Carbon::parse($contract->signing_date); - - foreach ($items as $item) { - $dueDate = null; - - if ($item->days_after_signing !== null) { - $dueDate = Carbon::parse($contract->signing_date)->addDays($item->days_after_signing); - } elseif ($item->days_after_previous !== null) { - $dueDate = $lastDueDate->copy()->addDays($item->days_after_previous); - } elseif ($item->due_date !== null) { - $dueDate = $item->due_date; + return DB::transaction(function () use ($contract, $template) { + if (! $template) { + // Ưu tiên template của dự án + $template = $contract->product?->project?->paymentTemplate; } - 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 (! $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, ]); - if ($dueDate) { - $lastDueDate = $dueDate; - } - } + $items = $template->items()->orderBy('installment_no')->get(); + $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; + }); } } diff --git a/app/Services/Forms/MailMergeService.php b/app/Services/Forms/MailMergeService.php index 5b71c27..852fb8d 100644 --- a/app/Services/Forms/MailMergeService.php +++ b/app/Services/Forms/MailMergeService.php @@ -72,35 +72,116 @@ class MailMergeService $evalExpression = $expression; foreach ($values as $key => $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 = str_replace(' ', '', $evalExpression); if (empty($evalExpression)) return 0; try { - // Eval an toàn với chỉ phép toán - $result = self::safeEval($evalExpression); + $result = self::safeCalculate($evalExpression); return (float) $result; } catch (\Throwable $e) { 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 - $expression = preg_replace('/[^0-9.\+\-\*\/\(\)\s]/', '', $expression); + // Tokenize: tách số và operators + $tokens = []; + $number = ''; + + 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; + } - if (empty($expression) || preg_match('/[a-zA-Z]/', $expression)) { + // 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'); } - // Dùng bc math nếu có, hoặc eval đơn giản - return (float) eval('return ' . $expression . ';'); + return (float) $evalStack[0]; } /**