Fix 3 loi nghiem trong: eval() -> safe parser, Contract::saved() infinite loop, DB Transaction for schedule generation

This commit is contained in:
2026-04-28 08:04:30 +00:00
parent 49aa20a634
commit e229da5e8c
7 changed files with 361 additions and 78 deletions

View File

@@ -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;']),
]),
]);
}

View File

@@ -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;
}
}
});
}

View File

@@ -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 , sẽ xóa tạo lại.
* Toàn bộ quá trình được bọc trong DB Transaction để đảm bảo tính toàn vẹn.
*/
public static function generateFromTemplate(Contract $contract, ?PaymentTemplate $template = null): PaymentSchedule
{
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;
});
}
}

View File

@@ -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];
}
/**