diff --git a/api/__pycache__/settings.cpython-313.pyc b/api/__pycache__/settings.cpython-313.pyc index 12cc0dca..a174e29b 100644 Binary files a/api/__pycache__/settings.cpython-313.pyc and b/api/__pycache__/settings.cpython-313.pyc differ diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc index d183de4a..41cce2d2 100644 Binary files a/app/__pycache__/__init__.cpython-313.pyc and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/apps.cpython-313.pyc b/app/__pycache__/apps.cpython-313.pyc index 679f532b..d348ce24 100644 Binary files a/app/__pycache__/apps.cpython-313.pyc and b/app/__pycache__/apps.cpython-313.pyc differ diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index 2f2c0ecb..710e3f98 100644 Binary files a/app/__pycache__/models.cpython-313.pyc and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/__pycache__/workflow_utils.cpython-313.pyc b/app/__pycache__/workflow_utils.cpython-313.pyc index 2094ebb0..5ded5f24 100644 Binary files a/app/__pycache__/workflow_utils.cpython-313.pyc and b/app/__pycache__/workflow_utils.cpython-313.pyc differ diff --git a/app/apps.py b/app/apps.py index 5d3d261f..afefba20 100644 --- a/app/apps.py +++ b/app/apps.py @@ -7,6 +7,18 @@ class AppConfig(AppConfig): def ready(self): import app.workflow_actions - from . import signals - signals.connect_signals() \ No newline at end of file + 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}") \ No newline at end of file diff --git a/app/management/__init__.py b/app/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/management/commands/__init__.py b/app/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/migrations/0363_alter_transaction_discount_unique_together_batch_job.py b/app/migrations/0363_alter_transaction_discount_unique_together_batch_job.py new file mode 100644 index 00000000..9569b415 --- /dev/null +++ b/app/migrations/0363_alter_transaction_discount_unique_together_batch_job.py @@ -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', + }, + ), + ] diff --git a/app/migrations/0364_alter_user_email.py b/app/migrations/0364_alter_user_email.py new file mode 100644 index 00000000..97bf04e7 --- /dev/null +++ b/app/migrations/0364_alter_user_email.py @@ -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), + ), + ] diff --git a/app/migrations/0365_payment_schedule_penalty_paid_and_more.py b/app/migrations/0365_payment_schedule_penalty_paid_and_more.py new file mode 100644 index 00000000..b9d8619a --- /dev/null +++ b/app/migrations/0365_payment_schedule_penalty_paid_and_more.py @@ -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), + ), + ] diff --git a/app/models.py b/app/models.py index 7e0d6e23..ecb2c9c6 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/run_batch_jobs.py b/app/run_batch_jobs.py deleted file mode 100644 index 613e74db..00000000 --- a/app/run_batch_jobs.py +++ /dev/null @@ -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.") diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 00000000..a68462e8 --- /dev/null +++ b/app/scheduler.py @@ -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.") diff --git a/app/workflow_utils.py b/app/workflow_utils.py index 45d1f455..c183069a 100644 --- a/app/workflow_utils.py +++ b/app/workflow_utils.py @@ -1,142 +1,494 @@ 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): return expr 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, + } + + 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) - 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 - - # ============================================= - # 2.1. Hàm xử lý list: $append(list, element) - # ============================================= - 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() + # 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) - # 1. Resolve the list - target_list = resolve_value(list_expr, context) - if target_list is None: - target_list = [] - - # Ensure it's a copy so we don't modify the original context variable directly - target_list = list(target_list) - - # 2. Resolve the element - resolved_element = resolve_value(element_expr, context) - - 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 - - target_list.append(element_to_append) - return target_list + # 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.2. Hàm tổng hợp list: $agg(list, operation, field?) + # 3. DATE FUNCTIONS # ============================================= - 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 + # $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) + + d1 = to_date(raw_d1) + d2 = to_date(raw_d2) + + unit = resolve_value(args[2], context).lower() if len(args) > 2 else 'days' + + #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 - # 1. Resolve the list - target_list = resolve_value(list_expr, context) - if target_list is None: - target_list = [] - - if not isinstance(target_list, list): - return 0 + if not (d1 and d2): + #print("[DEBUG date_diff] One or both dates are invalid. Returning 0.") # DEBUG + return 0 + + # Ensure we are comparing date objects, ignoring time + d1_date_only = d1.date() + d2_date_only = d2.date() + + #print(f"[DEBUG date_diff] d1_date_only: {d1_date_only}, d2_date_only: {d2_date_only}") # DEBUG - # 2. Perform operation - if operation == 'count': - return len(target_list) - - if operation == 'sum': - if not field_expr: + 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 + + #print(f"[DEBUG date_diff] Unit '{unit}' not recognized. Returning 0.") # DEBUG return 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) - 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) + 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 \ No newline at end of file + 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 \ No newline at end of file