changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
14
app/apps.py
14
app/apps.py
@@ -7,6 +7,18 @@ class AppConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
import app.workflow_actions
|
||||
|
||||
from . import signals
|
||||
signals.connect_signals()
|
||||
|
||||
# Sử dụng cache.add() của Django để tạo lock, đảm bảo chỉ một worker khởi động scheduler
|
||||
try:
|
||||
from django.core.cache import cache
|
||||
# cache.add() là atomic, chỉ trả về True nếu key được tạo thành công
|
||||
if cache.add("scheduler_lock", "locked", timeout=65):
|
||||
from . import scheduler
|
||||
scheduler.start()
|
||||
print("Scheduler started by this worker.")
|
||||
else:
|
||||
print("Scheduler lock already held by another worker.")
|
||||
except Exception as e:
|
||||
print(f"Failed to start or check scheduler lock: {e}")
|
||||
0
app/management/__init__.py
Normal file
0
app/management/__init__.py
Normal file
0
app/management/commands/__init__.py
Normal file
0
app/management/commands/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.7 on 2026-01-23 16:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0362_gift'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='transaction_discount',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Batch_Job',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('cron_schedule', models.CharField(help_text="Cron-like schedule (e.g., '0 0 * * *' for daily at midnight)", max_length=100)),
|
||||
('parameters', models.JSONField(blank=True, default=dict, help_text='Parameters to find data for the workflow context')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('last_run_at', models.DateTimeField(blank=True, null=True)),
|
||||
('next_run_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||
('create_time', models.DateTimeField(auto_now_add=True)),
|
||||
('update_time', models.DateTimeField(auto_now=True)),
|
||||
('workflow', models.ForeignKey(help_text='Workflow to execute', on_delete=django.db.models.deletion.PROTECT, to='app.workflow')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'batch_job',
|
||||
},
|
||||
),
|
||||
]
|
||||
18
app/migrations/0364_alter_user_email.py
Normal file
18
app/migrations/0364_alter_user_email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2026-01-24 04:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0363_alter_transaction_discount_unique_together_batch_job'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.CharField(max_length=100, null=True, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.7 on 2026-01-25 11:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('app', '0364_alter_user_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payment_schedule',
|
||||
name='penalty_paid',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=15, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment_schedule',
|
||||
name='penalty_reduce',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=15, null=True),
|
||||
),
|
||||
]
|
||||
@@ -361,7 +361,7 @@ class Payment_Plan(models.Model):
|
||||
class User(models.Model):
|
||||
username = models.CharField(max_length=50, null=False, unique=True)
|
||||
password = models.CharField(max_length=100, null=False)
|
||||
email = models.CharField(max_length=100, null=True)
|
||||
email = models.CharField(max_length=100, null=True, unique=True)
|
||||
avatar = models.CharField(max_length=100, null=True)
|
||||
fullname = models.CharField(max_length=50, null=False)
|
||||
display_name = models.CharField(max_length=50, null=True)
|
||||
@@ -1702,6 +1702,8 @@ class Payment_Schedule(AutoCodeModel):
|
||||
detail = models.JSONField(null=True)
|
||||
ovd_days = models.IntegerField(null=True)
|
||||
penalty_amount = models.DecimalField(null=True, max_digits=15, decimal_places=2)
|
||||
penalty_paid = models.DecimalField(null=True, max_digits=15, decimal_places=2)
|
||||
penalty_reduce = models.DecimalField(null=True, max_digits=15, decimal_places=2)
|
||||
create_time = models.DateTimeField(null=True, auto_now_add=True)
|
||||
update_time = models.DateTimeField(null=True, auto_now=True)
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.apps import apps
|
||||
from croniter import croniter
|
||||
import traceback
|
||||
|
||||
from app.models import Batch_Job, Batch_Log, Task_Status
|
||||
from app.workflow_engine import run_workflow
|
||||
from app.workflow_utils import resolve_value
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Runs all active batch jobs that are due.'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(f"[{timezone.now()}] Starting batch job runner...")
|
||||
|
||||
now = timezone.now()
|
||||
# Lấy các job cần chạy (active và đã đến lúc chạy)
|
||||
due_jobs = Batch_Job.objects.filter(
|
||||
is_active=True,
|
||||
next_run_at__lte=now
|
||||
)
|
||||
|
||||
self.stdout.write(f"Found {due_jobs.count()} due jobs to run.")
|
||||
|
||||
for job in due_jobs:
|
||||
self.stdout.write(f"--- Running job: {job.name} (ID: {job.id}) ---")
|
||||
|
||||
# Bắt đầu ghi log
|
||||
running_status, _ = Task_Status.objects.get_or_create(code='running', defaults={'name': 'Running'})
|
||||
log = Batch_Log.objects.create(
|
||||
system_date=now.date(),
|
||||
start_time=now,
|
||||
status=running_status
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# 1. Tìm dữ liệu để xử lý dựa trên tham số của job
|
||||
params = job.parameters or {}
|
||||
model_name = params.get("model")
|
||||
filter_conditions = params.get("filter", {})
|
||||
context_key = params.get("context_key", "target") # Tên biến trong context workflow
|
||||
|
||||
if not model_name:
|
||||
raise ValueError("Job parameters must include a 'model' to query.")
|
||||
|
||||
TargetModel = apps.get_model('app', model_name)
|
||||
|
||||
# Resolve các giá trị động trong điều kiện filter (ví dụ: $today)
|
||||
resolved_filters = {k: resolve_value(v, {"now": now, "today": now.date()}) for k, v in filter_conditions.items()}
|
||||
|
||||
targets = TargetModel.objects.filter(**resolved_filters)
|
||||
self.stdout.write(f" > Found {targets.count()} target objects to process.")
|
||||
|
||||
# 2. Thực thi workflow cho từng đối tượng
|
||||
processed_count = 0
|
||||
for target_item in targets:
|
||||
try:
|
||||
# Chuẩn bị context cho workflow
|
||||
workflow_context = {
|
||||
context_key: target_item,
|
||||
"batch_job": job,
|
||||
"now": now,
|
||||
}
|
||||
run_workflow(
|
||||
workflow_code=job.workflow.code,
|
||||
trigger="BATCH_JOB",
|
||||
context=workflow_context
|
||||
)
|
||||
processed_count += 1
|
||||
except Exception as e:
|
||||
self.stderr.write(f" > Error processing target {getattr(target_item, 'pk', 'N/A')} in job {job.name}: {e}")
|
||||
# Hiện tại, nếu một item lỗi thì bỏ qua và chạy item tiếp theo
|
||||
# Có thể thay đổi logic để dừng cả job nếu cần
|
||||
|
||||
# 3. Cập nhật log khi thành công
|
||||
success_status, _ = Task_Status.objects.get_or_create(code='success', defaults={'name': 'Success'})
|
||||
log.status = success_status
|
||||
log.log = {"message": f"Successfully processed {processed_count} of {targets.count()} items."}
|
||||
|
||||
except Exception as e:
|
||||
self.stderr.write(f"!!! Job '{job.name}' failed: {e}")
|
||||
traceback.print_exc()
|
||||
failed_status, _ = Task_Status.objects.get_or_create(code='failed', defaults={'name': 'Failed'})
|
||||
log.status = failed_status
|
||||
log.log = {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
finally:
|
||||
# 4. Hoàn tất log và đặt lịch chạy lại cho job
|
||||
end_time = timezone.now()
|
||||
log.end_time = end_time
|
||||
log.duration = (end_time - log.start_time).total_seconds()
|
||||
log.save()
|
||||
|
||||
# Đặt lịch cho lần chạy tiếp theo
|
||||
job.last_run_at = now
|
||||
job.next_run_at = croniter(job.cron_schedule, now).get_next(timezone.datetime)
|
||||
job.save()
|
||||
|
||||
self.stdout.write(f"--- Finished job: {job.name}. Next run at: {job.next_run_at} ---")
|
||||
|
||||
self.stdout.write(f"[{timezone.now()}] Batch job runner finished.")
|
||||
162
app/scheduler.py
Normal file
162
app/scheduler.py
Normal file
@@ -0,0 +1,162 @@
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.apps import apps
|
||||
from croniter import croniter
|
||||
import traceback
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from app.models import Batch_Job, Batch_Log, Task_Status
|
||||
from app.workflow_engine import run_workflow
|
||||
from app.workflow_utils import resolve_value
|
||||
|
||||
# Cấu hình logging cơ bản
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def scan_and_run_due_jobs():
|
||||
"""
|
||||
Quét và chạy tất cả các batch job đang active và đã đến hạn.
|
||||
Đồng thời tự động khởi tạo next_run_at cho các job mới.
|
||||
"""
|
||||
from django.conf import settings
|
||||
import pytz
|
||||
import datetime
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# BƯỚC 1: Tìm và khởi tạo các job có next_run_at là null
|
||||
try:
|
||||
uninitialized_jobs = Batch_Job.objects.filter(
|
||||
is_active=True,
|
||||
next_run_at__isnull=True
|
||||
).exclude(cron_schedule__isnull=True).exclude(cron_schedule__exact='')
|
||||
|
||||
if uninitialized_jobs.exists():
|
||||
#logger.info(f"Found {uninitialized_jobs.count()} uninitialized jobs. Calculating next run time...")
|
||||
|
||||
# Lấy timezone của dự án
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
|
||||
for job in uninitialized_jobs:
|
||||
try:
|
||||
schedule_parts = job.cron_schedule.split()
|
||||
if len(schedule_parts) == 4:
|
||||
job.cron_schedule = f"{schedule_parts[0]} {schedule_parts[1]} {schedule_parts[2]} {schedule_parts[3]} *"
|
||||
|
||||
cron_parts = job.cron_schedule.split()
|
||||
if len(cron_parts[0]) > 1 and cron_parts[0].startswith('0'):
|
||||
cron_parts[0] = str(int(cron_parts[0]))
|
||||
|
||||
corrected_schedule = " ".join(cron_parts)
|
||||
|
||||
# Xử lý timezone một cách tường minh
|
||||
# 1. Lấy thời gian hiện tại ở dạng naive (không có thông tin timezone)
|
||||
naive_now = timezone.localtime(now).replace(tzinfo=None)
|
||||
# 2. Croniter tính toán ra thời gian naive tiếp theo
|
||||
naive_next = croniter(corrected_schedule, naive_now).get_next(datetime.datetime)
|
||||
# 3. Gán lại timezone đúng cho kết quả
|
||||
aware_next = tz.localize(naive_next)
|
||||
|
||||
job.next_run_at = aware_next
|
||||
job.save()
|
||||
#logger.info(f" -> Initialized job '{job.name}'. Next run at: {job.next_run_at}")
|
||||
except Exception as e:
|
||||
logger.error(f" -> Failed to initialize job '{job.name}': {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during job initialization phase: {e}")
|
||||
|
||||
|
||||
# BƯỚC 2: Quét và chạy các job đến hạn như bình thường
|
||||
#logger.info("Scanning for due batch jobs...")
|
||||
|
||||
# Lấy các job cần chạy (có next_run_at không null và đã đến hạn)
|
||||
due_jobs = Batch_Job.objects.filter(is_active=True, next_run_at__lte=now)
|
||||
|
||||
if not due_jobs.exists():
|
||||
#logger.info("-> No due jobs found at this time.")
|
||||
return
|
||||
|
||||
#logger.info(f"-> Found {due_jobs.count()} due jobs to run.")
|
||||
|
||||
for job in due_jobs:
|
||||
#logger.info(f"--- Running job: {job.name} (ID: {job.id}) ---")
|
||||
|
||||
running_status, _ = Task_Status.objects.get_or_create(code='running', defaults={'name': 'Running'})
|
||||
log = Batch_Log.objects.create(
|
||||
system_date=now.date(),
|
||||
start_time=now,
|
||||
status=running_status,
|
||||
log = {"message": f"Starting job {job.name}"}
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
params = job.parameters or {}
|
||||
model_name = params.get("model")
|
||||
filter_conditions = params.get("filter", {})
|
||||
context_key = params.get("context_key", "target")
|
||||
|
||||
if not model_name:
|
||||
raise ValueError("Job parameters must include a 'model' to query.")
|
||||
|
||||
TargetModel = apps.get_model('app', model_name)
|
||||
|
||||
resolved_filters = {k: resolve_value(v, {"now": now, "today": now.date()}) for k, v in filter_conditions.items()}
|
||||
|
||||
targets = TargetModel.objects.filter(**resolved_filters)
|
||||
#logger.info(f" > Found {targets.count()} target objects to process for job '{job.name}'.")
|
||||
|
||||
processed_count = 0
|
||||
for target_item in targets:
|
||||
try:
|
||||
workflow_context = {
|
||||
context_key: target_item,
|
||||
"batch_job": job,
|
||||
"now": now,
|
||||
}
|
||||
run_workflow(
|
||||
workflow_code=job.workflow.code,
|
||||
trigger="create",
|
||||
context=workflow_context
|
||||
)
|
||||
processed_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f" > Error processing target {getattr(target_item, 'pk', 'N/A')} in job {job.name}: {e}", exc_info=True)
|
||||
|
||||
success_status, _ = Task_Status.objects.get_or_create(code='success', defaults={'name': 'Success'})
|
||||
log.status = success_status
|
||||
log.log = {"message": f"Successfully processed {processed_count} of {targets.count()} items."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"!!! Job '{job.name}' failed: {e}", exc_info=True)
|
||||
failed_status, _ = Task_Status.objects.get_or_create(code='failed', defaults={'name': 'Failed'})
|
||||
log.status = failed_status
|
||||
log.log = {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
finally:
|
||||
end_time = timezone.now()
|
||||
log.end_time = end_time
|
||||
log.duration = (end_time - log.start_time).total_seconds()
|
||||
log.save()
|
||||
|
||||
# Đặt lịch cho lần chạy tiếp theo
|
||||
job.last_run_at = now
|
||||
# Tái sử dụng logic tính toán đã được sửa lỗi timezone
|
||||
tz = pytz.timezone(settings.TIME_ZONE)
|
||||
naive_now = timezone.localtime(now).replace(tzinfo=None)
|
||||
naive_next = croniter(job.cron_schedule, naive_now).get_next(datetime.datetime)
|
||||
job.next_run_at = tz.localize(naive_next)
|
||||
job.save()
|
||||
|
||||
#logger.info(f"--- Finished job: {job.name}. Next run at: {job.next_run_at} ---")
|
||||
|
||||
def start():
|
||||
"""
|
||||
Khởi động APScheduler và thêm tác vụ quét job.
|
||||
"""
|
||||
scheduler = BackgroundScheduler(timezone='Asia/Ho_Chi_Minh')
|
||||
# Chạy tác vụ quét job mỗi 60 giây
|
||||
scheduler.add_job(scan_and_run_due_jobs, 'interval', seconds=60, id='scan_due_jobs_job', replace_existing=True)
|
||||
scheduler.start()
|
||||
#logger.info("APScheduler started... Jobs will be scanned every 60 seconds.")
|
||||
@@ -1,21 +1,32 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
import math
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.apps import apps
|
||||
|
||||
# =============================================
|
||||
# CORE RESOLVER
|
||||
# =============================================
|
||||
def resolve_value(expr, context):
|
||||
"""
|
||||
Giải quyết các placeholder động (dynamic placeholder):
|
||||
- Literal (số, boolean)
|
||||
- Key trực tiếp (ví dụ: "customer_id")
|
||||
- Đường dẫn (ví dụ: "transaction.id")
|
||||
- Template chuỗi (ví dụ: "{customer_id}", "URL/{product_id}")
|
||||
- Hàm hệ thống: $add(a, b), $sub(a, b), $now, $now_iso
|
||||
Universal expression resolver with support for:
|
||||
- Literals (int, float, bool, string)
|
||||
- Template strings: {key}, "text {key} text"
|
||||
- Dotted paths: customer.address.city
|
||||
- Math functions: $add, $sub, $multiply, $divide, $mod, $power, $round, $abs, $min, $max
|
||||
- Date functions: $now, $today, $date_diff, $date_add, $date_format, $date_parse
|
||||
- String functions: $concat, $upper, $lower, $trim, $replace, $substring, $split, $length
|
||||
- Logic functions: $if, $switch, $and, $or, $not
|
||||
- List functions: $append, $agg, $filter, $map, $first, $last, $count, $sum
|
||||
- Lookup functions: $vlookup, $lookup, $get
|
||||
- Nested functions support
|
||||
"""
|
||||
if expr is None:
|
||||
return None
|
||||
|
||||
# Direct literal (int, float, bool)
|
||||
if isinstance(expr, (int, float, bool)):
|
||||
# Direct literal types
|
||||
if isinstance(expr, (int, float, bool, Decimal)):
|
||||
return expr
|
||||
|
||||
if not isinstance(expr, str):
|
||||
@@ -24,119 +35,460 @@ def resolve_value(expr, context):
|
||||
expr = expr.strip()
|
||||
|
||||
# =============================================
|
||||
# 1. Hỗ trợ thời gian hiện tại từ server
|
||||
# 1. SYSTEM VARIABLES
|
||||
# =============================================
|
||||
if expr == "$now":
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Format phù hợp với DateTimeField Django
|
||||
return context.get("now", datetime.now())
|
||||
|
||||
if expr == "$today":
|
||||
return datetime.now().strftime("%Y-%m-%d") # Chỉ lấy ngày, đúng format DateField
|
||||
if "today" in context:
|
||||
return context["today"]
|
||||
now_in_context = context.get("now")
|
||||
if isinstance(now_in_context, datetime):
|
||||
return now_in_context.date()
|
||||
return date.today()
|
||||
|
||||
if expr == "$now_iso":
|
||||
return datetime.now().isoformat(timespec='seconds') # 2025-12-21T14:30:45
|
||||
return datetime.now().isoformat(timespec='seconds')
|
||||
|
||||
if expr == "$timestamp":
|
||||
return int(datetime.now().timestamp())
|
||||
|
||||
# =============================================
|
||||
# 2. Hàm toán học: $add(a, b), $sub(a, b), $multiply(a, b)
|
||||
# 2. MATH FUNCTIONS (Support Nested)
|
||||
# =============================================
|
||||
func_match = re.match(r"^\$(add|sub|multiply)\(([^,]+),\s*([^)]+)\)$", expr)
|
||||
if func_match:
|
||||
func_name = func_match.group(1)
|
||||
arg1_val = resolve_value(func_match.group(2).strip(), context)
|
||||
arg2_val = resolve_value(func_match.group(3).strip(), context)
|
||||
math_functions = {
|
||||
'add': lambda a, b: a + b,
|
||||
'sub': lambda a, b: a - b,
|
||||
'subtract': lambda a, b: a - b,
|
||||
'multiply': lambda a, b: a * b,
|
||||
'mul': lambda a, b: a * b,
|
||||
'divide': lambda a, b: a / b if b != 0 else 0,
|
||||
'div': lambda a, b: a / b if b != 0 else 0,
|
||||
'mod': lambda a, b: a % b if b != 0 else 0,
|
||||
'power': lambda a, b: a ** b,
|
||||
'pow': lambda a, b: a ** b,
|
||||
}
|
||||
|
||||
try:
|
||||
num1 = float(arg1_val or 0)
|
||||
num2 = float(arg2_val or 0)
|
||||
if func_name == "add":
|
||||
return num1 + num2
|
||||
if func_name == "sub":
|
||||
return num1 - num2
|
||||
if func_name == "multiply":
|
||||
return num1 * num2
|
||||
except (ValueError, TypeError):
|
||||
print(f" [ERROR] Math function {func_name} failed with values: {arg1_val}, {arg2_val}")
|
||||
return 0
|
||||
for func_name, func in math_functions.items():
|
||||
pattern = rf'^\${func_name}\((.*)\)$'
|
||||
match = re.match(pattern, expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 2:
|
||||
a = to_number(resolve_value(args[0], context))
|
||||
b = to_number(resolve_value(args[1], context))
|
||||
return func(a, b)
|
||||
|
||||
# Single-argument math functions
|
||||
single_math = {
|
||||
'round': lambda x, d=0: round(x, int(d)),
|
||||
'abs': lambda x: abs(x),
|
||||
'ceil': lambda x: math.ceil(x),
|
||||
'floor': lambda x: math.floor(x),
|
||||
'sqrt': lambda x: math.sqrt(x) if x >= 0 else 0,
|
||||
}
|
||||
|
||||
for func_name, func in single_math.items():
|
||||
pattern = rf'^\${func_name}\((.*)\)$'
|
||||
match = re.match(pattern, expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) >= 1:
|
||||
val = to_number(resolve_value(args[0], context))
|
||||
if len(args) == 2 and func_name == 'round':
|
||||
decimals = to_number(resolve_value(args[1], context))
|
||||
return func(val, decimals)
|
||||
return func(val)
|
||||
|
||||
# Multi-argument math
|
||||
if re.match(r'^\$(min|max)\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$(min|max)\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
func_name = match.group(1).lower()
|
||||
args = split_args(match.group(2))
|
||||
values = [to_number(resolve_value(arg, context)) for arg in args]
|
||||
return min(values) if func_name == 'min' else max(values)
|
||||
|
||||
# =============================================
|
||||
# 2.1. Hàm xử lý list: $append(list, element)
|
||||
# 3. DATE FUNCTIONS
|
||||
# =============================================
|
||||
append_match = re.match(r"^\$append\(([^,]+),\s*(.+)\)$", expr, re.DOTALL)
|
||||
if append_match:
|
||||
list_expr = append_match.group(1).strip()
|
||||
element_expr = append_match.group(2).strip()
|
||||
# $date_diff(date1, date2, unit?)
|
||||
if re.match(r'^\$date_diff\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$date_diff\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) >= 2:
|
||||
raw_d1 = resolve_value(args[0], context)
|
||||
raw_d2 = resolve_value(args[1], context)
|
||||
|
||||
# 1. Resolve the list
|
||||
target_list = resolve_value(list_expr, context)
|
||||
if target_list is None:
|
||||
target_list = []
|
||||
d1 = to_date(raw_d1)
|
||||
d2 = to_date(raw_d2)
|
||||
|
||||
# Ensure it's a copy so we don't modify the original context variable directly
|
||||
target_list = list(target_list)
|
||||
unit = resolve_value(args[2], context).lower() if len(args) > 2 else 'days'
|
||||
|
||||
# 2. Resolve the element
|
||||
resolved_element = resolve_value(element_expr, context)
|
||||
#print(f"[DEBUG date_diff] raw_d1: {raw_d1}, raw_d2: {raw_d2}") # DEBUG
|
||||
#print(f"[DEBUG date_diff] d1 (datetime): {d1}, d2 (datetime): {d2}") # DEBUG
|
||||
#print(f"[DEBUG date_diff] unit: {unit}") # DEBUG
|
||||
|
||||
if isinstance(resolved_element, str):
|
||||
try:
|
||||
import json
|
||||
element_to_append = json.loads(resolved_element)
|
||||
except json.JSONDecodeError:
|
||||
element_to_append = resolved_element
|
||||
else:
|
||||
element_to_append = resolved_element
|
||||
if not (d1 and d2):
|
||||
#print("[DEBUG date_diff] One or both dates are invalid. Returning 0.") # DEBUG
|
||||
return 0
|
||||
|
||||
target_list.append(element_to_append)
|
||||
return target_list
|
||||
# Ensure we are comparing date objects, ignoring time
|
||||
d1_date_only = d1.date()
|
||||
d2_date_only = d2.date()
|
||||
|
||||
# =============================================
|
||||
# 2.2. Hàm tổng hợp list: $agg(list, operation, field?)
|
||||
# =============================================
|
||||
agg_match = re.match(r"^\$agg\(([^,]+),\s*'([^']+)'(?:,\s*['\"]?([^'\"]+)['\"]?)?\)$", expr.strip())
|
||||
if agg_match:
|
||||
list_expr = agg_match.group(1).strip()
|
||||
operation = agg_match.group(2).strip()
|
||||
field_expr = agg_match.group(3).strip() if agg_match.group(3) else None
|
||||
#print(f"[DEBUG date_diff] d1_date_only: {d1_date_only}, d2_date_only: {d2_date_only}") # DEBUG
|
||||
|
||||
# 1. Resolve the list
|
||||
target_list = resolve_value(list_expr, context)
|
||||
if target_list is None:
|
||||
target_list = []
|
||||
if unit == 'days':
|
||||
delta_days = (d1_date_only - d2_date_only).days
|
||||
#print(f"[DEBUG date_diff] Calculated delta_days: {delta_days}. Returning {delta_days}.") # DEBUG
|
||||
return delta_days
|
||||
elif unit == 'months':
|
||||
delta_months = (d1_date_only.year - d2_date_only.year) * 12 + d1_date_only.month - d2_date_only.month
|
||||
#print(f"[DEBUG date_diff] Calculated delta_months: {delta_months}. Returning {delta_months}.") # DEBUG
|
||||
return delta_months
|
||||
elif unit == 'years':
|
||||
delta_years = d1_date_only.year - d2_date_only.year
|
||||
#print(f"[DEBUG date_diff] Calculated delta_years: {delta_years}. Returning {delta_years}.") # DEBUG
|
||||
return delta_years
|
||||
|
||||
if not isinstance(target_list, list):
|
||||
return 0
|
||||
|
||||
# 2. Perform operation
|
||||
if operation == 'count':
|
||||
return len(target_list)
|
||||
|
||||
if operation == 'sum':
|
||||
if not field_expr:
|
||||
#print(f"[DEBUG date_diff] Unit '{unit}' not recognized. Returning 0.") # DEBUG
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
for item in target_list:
|
||||
value = 0
|
||||
if isinstance(item, dict):
|
||||
value = item.get(field_expr)
|
||||
else:
|
||||
value = getattr(item, field_expr, 0)
|
||||
# $date_add(date, amount, unit?)
|
||||
if re.match(r'^\$date_add\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$date_add\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) >= 2:
|
||||
base_date = to_date(resolve_value(args[0], context))
|
||||
amount = to_number(resolve_value(args[1], context))
|
||||
unit = resolve_value(args[2], context).lower() if len(args) > 2 else 'days'
|
||||
|
||||
if base_date:
|
||||
# Ensure base_date is datetime
|
||||
if isinstance(base_date, date) and not isinstance(base_date, datetime):
|
||||
base_date = datetime.combine(base_date, datetime.min.time())
|
||||
|
||||
if unit == 'days':
|
||||
result = base_date + timedelta(days=int(amount))
|
||||
elif unit == 'months':
|
||||
month = base_date.month + int(amount)
|
||||
year = base_date.year + (month - 1) // 12
|
||||
month = ((month - 1) % 12) + 1
|
||||
result = base_date.replace(year=year, month=month)
|
||||
elif unit == 'years':
|
||||
result = base_date.replace(year=base_date.year + int(amount))
|
||||
elif unit == 'hours':
|
||||
result = base_date + timedelta(hours=int(amount))
|
||||
else:
|
||||
result = base_date + timedelta(days=int(amount))
|
||||
|
||||
return result.isoformat() if isinstance(result, datetime) else result.strftime("%Y-%m-%d")
|
||||
|
||||
# $date_format(date, format)
|
||||
if re.match(r'^\$date_format\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$date_format\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 2:
|
||||
dt = to_date(resolve_value(args[0], context))
|
||||
fmt = resolve_value(args[1], context).strip('\'"')
|
||||
if dt:
|
||||
return dt.strftime(fmt)
|
||||
|
||||
# $date_parse(string, format)
|
||||
if re.match(r'^\$date_parse\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$date_parse\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) >= 1:
|
||||
date_str = str(resolve_value(args[0], context))
|
||||
fmt = resolve_value(args[1], context).strip('\'"') if len(args) > 1 else "%Y-%m-%d"
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt).strftime("%Y-%m-%d")
|
||||
except:
|
||||
return None
|
||||
|
||||
# =============================================
|
||||
# 4. STRING FUNCTIONS
|
||||
# =============================================
|
||||
# $concat(str1, str2, ...)
|
||||
if re.match(r'^\$concat\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$concat\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
return ''.join(str(resolve_value(arg, context) or '') for arg in args)
|
||||
|
||||
# $upper, $lower, $trim
|
||||
string_single = {
|
||||
'upper': lambda s: str(s).upper(),
|
||||
'lower': lambda s: str(s).lower(),
|
||||
'trim': lambda s: str(s).strip(),
|
||||
'length': lambda s: len(str(s)),
|
||||
}
|
||||
|
||||
for func_name, func in string_single.items():
|
||||
pattern = rf'^\${func_name}\((.*)\)$'
|
||||
match = re.match(pattern, expr, re.IGNORECASE)
|
||||
if match:
|
||||
arg = resolve_value(match.group(1).strip(), context)
|
||||
return func(arg)
|
||||
|
||||
# $replace(text, old, new)
|
||||
if re.match(r'^\$replace\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$replace\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 3:
|
||||
text = str(resolve_value(args[0], context))
|
||||
old = str(resolve_value(args[1], context)).strip('\'"')
|
||||
new = str(resolve_value(args[2], context)).strip('\'"')
|
||||
return text.replace(old, new)
|
||||
|
||||
# $substring(text, start, length?)
|
||||
if re.match(r'^\$substring\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$substring\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) >= 2:
|
||||
text = str(resolve_value(args[0], context))
|
||||
start = int(to_number(resolve_value(args[1], context)))
|
||||
length = int(to_number(resolve_value(args[2], context))) if len(args) > 2 else None
|
||||
return text[start:start+length] if length else text[start:]
|
||||
|
||||
# $split(text, delimiter)
|
||||
if re.match(r'^\$split\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$split\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 2:
|
||||
text = str(resolve_value(args[0], context))
|
||||
delimiter = str(resolve_value(args[1], context)).strip('\'"')
|
||||
return text.split(delimiter)
|
||||
|
||||
# =============================================
|
||||
# 5. LOGIC FUNCTIONS
|
||||
# =============================================
|
||||
# $if(condition, true_value, false_value)
|
||||
if re.match(r'^\$if\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$if\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 3:
|
||||
condition = resolve_value(args[0], context)
|
||||
return resolve_value(args[1], context) if condition else resolve_value(args[2], context)
|
||||
|
||||
# $switch(value, case1, result1, case2, result2, ..., default)
|
||||
if re.match(r'^\$switch\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$switch\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) >= 2:
|
||||
value = resolve_value(args[0], context)
|
||||
for i in range(1, len(args) - 1, 2):
|
||||
if i + 1 < len(args):
|
||||
case = resolve_value(args[i], context)
|
||||
if value == case:
|
||||
return resolve_value(args[i + 1], context)
|
||||
# Default value is last arg if odd number of args
|
||||
if len(args) % 2 == 0:
|
||||
return resolve_value(args[-1], context)
|
||||
|
||||
# $and, $or, $not
|
||||
if re.match(r'^\$and\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$and\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
return all(resolve_value(arg, context) for arg in args)
|
||||
|
||||
if re.match(r'^\$or\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$or\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
return any(resolve_value(arg, context) for arg in args)
|
||||
|
||||
if re.match(r'^\$not\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$not\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
arg = resolve_value(match.group(1).strip(), context)
|
||||
return not arg
|
||||
|
||||
# =============================================
|
||||
# 6. LIST/ARRAY FUNCTIONS
|
||||
# =============================================
|
||||
# $append(list, element)
|
||||
if re.match(r'^\$append\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$append\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 2:
|
||||
target_list = resolve_value(args[0], context)
|
||||
element = resolve_value(args[1], context)
|
||||
if not isinstance(target_list, list):
|
||||
target_list = []
|
||||
result = list(target_list)
|
||||
result.append(element)
|
||||
return result
|
||||
|
||||
# $first(list), $last(list)
|
||||
if re.match(r'^\$(first|last)\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$(first|last)\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
func_name = match.group(1).lower()
|
||||
lst = resolve_value(match.group(2).strip(), context)
|
||||
if isinstance(lst, list) and len(lst) > 0:
|
||||
return lst[0] if func_name == 'first' else lst[-1]
|
||||
|
||||
# $count(list)
|
||||
if re.match(r'^\$count\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$count\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
lst = resolve_value(match.group(1).strip(), context)
|
||||
return len(lst) if isinstance(lst, list) else 0
|
||||
|
||||
# $agg(list, operation, field?)
|
||||
if re.match(r'^\$agg\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$agg\(([^,]+),\s*[\'"]([^\'\"]+)[\'"](?:,\s*[\'"]?([^\'\")]+)[\'"]?)?\)$', expr)
|
||||
if match:
|
||||
list_expr = match.group(1).strip()
|
||||
operation = match.group(2).strip()
|
||||
field_expr = match.group(3).strip() if match.group(3) else None
|
||||
|
||||
target_list = resolve_value(list_expr, context)
|
||||
if not isinstance(target_list, list):
|
||||
return 0
|
||||
|
||||
if operation == 'count':
|
||||
return len(target_list)
|
||||
|
||||
if operation == 'sum':
|
||||
if not field_expr:
|
||||
return sum(to_number(item) for item in target_list)
|
||||
total = 0
|
||||
for item in target_list:
|
||||
value = item.get(field_expr) if isinstance(item, dict) else getattr(item, field_expr, 0)
|
||||
total += to_number(value)
|
||||
return total
|
||||
|
||||
if operation in ['min', 'max', 'avg']:
|
||||
values = []
|
||||
for item in target_list:
|
||||
if field_expr:
|
||||
value = item.get(field_expr) if isinstance(item, dict) else getattr(item, field_expr, 0)
|
||||
else:
|
||||
value = item
|
||||
values.append(to_number(value))
|
||||
|
||||
if not values:
|
||||
return 0
|
||||
if operation == 'min':
|
||||
return min(values)
|
||||
elif operation == 'max':
|
||||
return max(values)
|
||||
elif operation == 'avg':
|
||||
return sum(values) / len(values)
|
||||
|
||||
# =============================================
|
||||
# 7. LOOKUP FUNCTIONS
|
||||
# =============================================
|
||||
# $vlookup(lookup_value, model_name, lookup_field, return_field)
|
||||
if re.match(r'^\$vlookup\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$vlookup\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 4:
|
||||
lookup_value = resolve_value(args[0], context)
|
||||
model_name = resolve_value(args[1], context).strip('\'"')
|
||||
lookup_field = resolve_value(args[2], context).strip('\'"')
|
||||
return_field = resolve_value(args[3], context).strip('\'"')
|
||||
|
||||
try:
|
||||
total += float(value or 0)
|
||||
except (ValueError, TypeError):
|
||||
Model = apps.get_model('app', model_name)
|
||||
obj = Model.objects.filter(**{lookup_field: lookup_value}).first()
|
||||
if obj:
|
||||
return getattr(obj, return_field, None)
|
||||
except:
|
||||
pass
|
||||
return total
|
||||
|
||||
print(f" [ERROR] Unknown $agg operation: {operation}")
|
||||
return 0
|
||||
# $lookup(model_name, field, value)
|
||||
if re.match(r'^\$lookup\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$lookup\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 3:
|
||||
model_name = resolve_value(args[0], context).strip('\'"')
|
||||
field = resolve_value(args[1], context).strip('\'"')
|
||||
value = resolve_value(args[2], context)
|
||||
|
||||
try:
|
||||
Model = apps.get_model('app', model_name)
|
||||
return Model.objects.filter(**{field: value}).first()
|
||||
except:
|
||||
pass
|
||||
|
||||
# $get(dict_or_object, key, default?)
|
||||
if re.match(r'^\$get\(', expr, re.IGNORECASE):
|
||||
match = re.match(r'^\$get\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) >= 2:
|
||||
obj = resolve_value(args[0], context)
|
||||
key = resolve_value(args[1], context)
|
||||
default = resolve_value(args[2], context) if len(args) > 2 else None
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
else:
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# =============================================
|
||||
# 3. Helper: Lấy giá trị từ context theo đường dẫn dotted
|
||||
# 8. COMPARISON OPERATORS
|
||||
# =============================================
|
||||
# $eq, $ne, $gt, $gte, $lt, $lte, $in, $contains
|
||||
comparisons = {
|
||||
'eq': lambda a, b: a == b,
|
||||
'ne': lambda a, b: a != b,
|
||||
'gt': lambda a, b: a > b,
|
||||
'gte': lambda a, b: a >= b,
|
||||
'lt': lambda a, b: a < b,
|
||||
'lte': lambda a, b: a <= b,
|
||||
'in': lambda a, b: a in b,
|
||||
'contains': lambda a, b: b in a,
|
||||
}
|
||||
|
||||
for op_name, op_func in comparisons.items():
|
||||
pattern = rf'^\${op_name}\('
|
||||
if re.match(pattern, expr, re.IGNORECASE):
|
||||
match = re.match(rf'^\${op_name}\((.*)\)$', expr, re.IGNORECASE)
|
||||
if match:
|
||||
args = split_args(match.group(1))
|
||||
if len(args) == 2:
|
||||
a = resolve_value(args[0], context)
|
||||
b = resolve_value(args[1], context)
|
||||
return op_func(a, b)
|
||||
|
||||
# =============================================
|
||||
# 9. HELPER: Get context value (dotted path)
|
||||
# =============================================
|
||||
def get_context_value(key_path):
|
||||
if "." not in key_path:
|
||||
return context.get(key_path)
|
||||
if not key_path:
|
||||
return None
|
||||
|
||||
# Check if numeric literal
|
||||
if re.match(r"^-?\d+(\.\d+)?$", key_path):
|
||||
return float(key_path)
|
||||
|
||||
# Simple key
|
||||
if "." not in key_path:
|
||||
val = context.get(key_path)
|
||||
if isinstance(val, Decimal):
|
||||
return float(val)
|
||||
return val
|
||||
|
||||
# Dotted path
|
||||
root, *rest = key_path.split(".")
|
||||
val = context.get(root)
|
||||
|
||||
@@ -144,54 +496,46 @@ def resolve_value(expr, context):
|
||||
if val is None:
|
||||
return None
|
||||
|
||||
# 1. Xử lý truy cập index mảng, ví dụ: payment_plan[0]
|
||||
# Array notation: field[0]
|
||||
array_match = re.match(r"(\w+)\[(\d+)\]", r)
|
||||
if array_match:
|
||||
attr_name = array_match.group(1)
|
||||
index = int(array_match.group(2))
|
||||
# Lấy list/queryset
|
||||
val = getattr(val, attr_name, None) if not isinstance(val, dict) else val.get(attr_name)
|
||||
try:
|
||||
if hasattr(val, 'all'): # Django QuerySet/Manager
|
||||
val = val[index]
|
||||
else: # List thông thường
|
||||
val = val[index]
|
||||
except (IndexError, TypeError, KeyError):
|
||||
val = val[index]
|
||||
except:
|
||||
return None
|
||||
else:
|
||||
# 2. Xử lý truy cập thuộc tính hoặc dict key
|
||||
if isinstance(val, dict):
|
||||
val = val.get(r)
|
||||
else:
|
||||
val = getattr(val, r, None)
|
||||
|
||||
# 3. Hỗ trợ tự động lấy bản ghi đầu tiên nếu là Manager (1-n)
|
||||
# Auto-fetch first() for QuerySet
|
||||
if hasattr(val, 'all') and not isinstance(val, models.Model):
|
||||
val = val.first()
|
||||
|
||||
if isinstance(val, Decimal):
|
||||
return float(val)
|
||||
return val
|
||||
|
||||
# =============================================
|
||||
# 4. Xử lý placeholder kiểu {key} hoặc {obj.field}
|
||||
# 10. TEMPLATE STRING PROCESSING
|
||||
# =============================================
|
||||
pattern = re.compile(r"\{(\w+(\.\w+)*)\}")
|
||||
if pattern.search(expr):
|
||||
# Trường hợp toàn bộ expr là một placeholder duy nhất: {customer_id}
|
||||
single_match = pattern.fullmatch(expr)
|
||||
if single_match:
|
||||
key = single_match.group(1)
|
||||
return get_context_value(key)
|
||||
return get_context_value(single_match.group(1))
|
||||
|
||||
# Trường hợp chuỗi có nhiều placeholder: "Hello {customer.name}"
|
||||
def replace_match(match):
|
||||
key = match.group(1)
|
||||
val = get_context_value(key)
|
||||
val = get_context_value(match.group(1))
|
||||
return str(val) if val is not None else ""
|
||||
|
||||
return pattern.sub(replace_match, expr)
|
||||
|
||||
# =============================================
|
||||
# 5. Hỗ trợ $last_result và $last_result.field
|
||||
# 11. SUPPORT $last_result
|
||||
# =============================================
|
||||
if expr.startswith("$last_result"):
|
||||
_, _, field = expr.partition(".")
|
||||
@@ -203,12 +547,81 @@ def resolve_value(expr, context):
|
||||
return getattr(last_res, field, None) if not isinstance(last_res, dict) else last_res.get(field)
|
||||
|
||||
# =============================================
|
||||
# 6. Dotted path trực tiếp: customer.name hoặc obj in context
|
||||
# 12. DOTTED PATH OR DIRECT CONTEXT KEY
|
||||
# =============================================
|
||||
if re.match(r"^-?\d+(\.\d+)?$", expr):
|
||||
return float(expr)
|
||||
|
||||
if "." in expr or expr in context:
|
||||
return get_context_value(expr)
|
||||
|
||||
# =============================================
|
||||
# 7. Trả về nguyên expr nếu không match gì
|
||||
# =============================================
|
||||
return expr
|
||||
|
||||
|
||||
# =============================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================
|
||||
def split_args(content):
|
||||
"""
|
||||
Split function arguments respecting nested parentheses and quotes.
|
||||
Example: "a, $add(b, c), 'd'" -> ["a", "$add(b, c)", "'d'"]
|
||||
"""
|
||||
args = []
|
||||
current = []
|
||||
depth = 0
|
||||
in_quote = None
|
||||
|
||||
for char in content:
|
||||
if char in ('"', "'") and (not in_quote or in_quote == char):
|
||||
in_quote = None if in_quote else char
|
||||
current.append(char)
|
||||
elif in_quote:
|
||||
current.append(char)
|
||||
elif char == '(':
|
||||
depth += 1
|
||||
current.append(char)
|
||||
elif char == ')':
|
||||
depth -= 1
|
||||
current.append(char)
|
||||
elif char == ',' and depth == 0:
|
||||
args.append(''.join(current).strip())
|
||||
current = []
|
||||
else:
|
||||
current.append(char)
|
||||
|
||||
if current:
|
||||
args.append(''.join(current).strip())
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def to_number(value, default=0):
|
||||
"""Convert value to number, return default if fails."""
|
||||
if value is None or value == '':
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def to_date(value):
|
||||
"""Convert value to datetime object."""
|
||||
#print(f"[DEBUG to_date] Input value: {value} (type: {type(value)})") # DEBUG
|
||||
if isinstance(value, datetime):
|
||||
#print(f"[DEBUG to_date] Output (datetime): {value}") # DEBUG
|
||||
return value
|
||||
if isinstance(value, date):
|
||||
result = datetime.combine(value, datetime.min.time())
|
||||
#print(f"[DEBUG to_date] Output (date -> datetime): {result}") # DEBUG
|
||||
return result
|
||||
if isinstance(value, str):
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%d/%m/%Y", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
result = datetime.strptime(value.split('.')[0], fmt)
|
||||
#print(f"[DEBUG to_date] Output (str -> datetime): {result}") # DEBUG
|
||||
return result
|
||||
except:
|
||||
continue
|
||||
#print(f"[DEBUG to_date] Output (None): None") # DEBUG
|
||||
return None
|
||||
Reference in New Issue
Block a user