Fix 3 loi nghiem trong: eval() -> safe parser, Contract::saved() infinite loop, DB Transaction for schedule generation
This commit is contained in:
@@ -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;']),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user