Django ORM: Как округлить (усечь) число в запросе?

Я работаю с чувствительными валютными значениями. В моем случае мне нужно воспроизвести лист с его формулами. Дело в том, что мне нужно округлить в меньшую сторону валютное значение с 2 знаками после запятой. Практическим примером является число: 9809.4069, его нужно округлить до 9809.40, с усечением. В противном случае, обычная функция округления возвращает мне 9809.41.

Обс. По соображениям производительности мне нужно свести все значения в один запрос. Обычные способы, с помощью обычных функций, таких как round(), не работают внутри функций, не относящихся к запросам.

Ну, мой запрос работает полностью FINE и приносит все, что я хочу, проблема в полях с функцией Round(), которая возвращает мне "неправильное" значение.

Запрос:

activetime_qs = activetime_qs.values(
            'user', 'user__team__name','user__foreign_id','user__location','user__team__cost_center'
            ).annotate(
                full_name=Concat(('user__first_name'),Value(' '),('user__last_name')),
                project_id=Subquery(UserProject.objects.filter(user_id=OuterRef('user')).values('project')[:1]),
                item_id=Subquery(Project.objects.filter(id=OuterRef('project_id')).values('item')[:1],),
                project_name=Subquery(Project.objects.filter(id=OuterRef('project_id')).values('name')[:1]),
                project_code=Subquery(Project.objects.filter(id=OuterRef('project_id')).values('code')[:1]),
                item_name=Subquery(Item.objects.filter(id=OuterRef('item_id')).values('name')[:1]),
                item_value=Subquery(Item.objects.filter(id=OuterRef('item_id')).values('unitary_value')[:1]),
                available_time=Sum('duration'),
                completed_tasks_amount=Case(
                    When(user__activity_type=ActivityType.DELIVERABLE,
                        then=Subquery(TaskExecution.objects.filter(members=OuterRef('user'), completed=True, 
                                start__date__gte=initial_date, start__date__lte=final_date)
                            .values('pk').annotate(count=Func(F('pk'), function='Count'))
                            .values('count'), output_field=IntegerField()
                            )
                    ),
                    default=1,
                ),
                availability_percentage=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION, 
                        then=Round(F('available_time') / expected_time, 3)),
                    default=0,
                    output_field=FloatField()
                ),
                subtotal_value=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION,
                        then=Round(F('item_value') * F('availability_percentage'),2)
                    ),
                    default=Round(F('item_value') * F('completed_tasks_amount'),3),
                    output_field=FloatField()
                ),
                availability_discount=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION,
                        then=Round(-1 + F('availability_percentage'),3),
                    ),
                    default=0,
                    output_field=FloatField()
                ),
                discount_value=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION, 
                        then=Round(F('item_value') - F('subtotal_value'),2),
                    ),
                    default=0,
                    output_field=FloatField()
                ),
            )
        return activetime_qs

Тогда я попробовал два варианта округления значений аннотаций в меньшую сторону.

  1. Используйте DecimalField в качестве output_field с точным количеством десятичных знаков, которое мне нужно, и установите метод десятичного округления на ROUND_DOWN.

*Мой пользовательский DecimalFieldClass:

class RoundedDecimalField(FloatField):

    def __init__(self, *args, **kwargs):
        super(RoundedDecimalField, self).__init__(*args, **kwargs)
        self.context = decimal.Context(prec=self.max_digits, rounding=decimal.ROUND_DOWN)

    def context(self):
        return decimal.Context(prec=self.max_digits, rounding=decimal.ROUND_DOWN)

    def to_python(self, value):
        self.context = decimal.Context(prec=self.max_digits, rounding=decimal.ROUND_DOWN)
        if value is None:
            return value
        if isinstance(value, float):
            if math.isnan(value):
                raise exceptions.ValidationError(
                    self.error_messages["invalid"],
                    code="invalid",
                    params={"value": value},
                )
            return self.context.create_decimal_from_float(value)
        try:
            return self.context.create_decimal(value)
        except (decimal.InvalidOperation, TypeError, ValueError):
            raise exceptions.ValidationError(
                self.error_messages["invalid"],
                code="invalid",
                params={"value": value},
            )

При таком подходе аннотируются значения:

                completed_tasks_amount=Case(
                    When(user__activity_type=ActivityType.DELIVERABLE,
                        then=Subquery(TaskExecution.objects.filter(members=OuterRef('user'), completed=True, 
                                start__date__gte=initial_date, start__date__lte=final_date)
                            .values('pk').annotate(count=Func(F('pk'), function='Count'))
                            .values('count'), output_field=IntegerField()
                            )
                    ),
                    default=1,
                ),
                availability_percentage=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION, then=Cast(Round(F('available_time') / expected_time, 3), output_field=RoundedDecimalField(max_digits=4,decimal_places=3))),
                    default=Cast(0, output_field=RoundedDecimalField(max_digits=4,decimal_places=3)),
                    output_field=RoundedDecimalField(max_digits=4,decimal_places=3)
                ),
                subtotal_value=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION,
                        then=Cast(F('item_value') * F('availability_percentage'),output_field=RoundedDecimalField(max_digits=9,decimal_places=3))
                    ),
                    default=Cast(F('item_value') * F('completed_tasks_amount'),output_field=RoundedDecimalField(max_digits=9,decimal_places=3)), 
                    output_field=RoundedDecimalField(max_digits=9,decimal_places=4), # DELIVERABLE employee 
                ),
                availability_discount=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION,
                        then=Cast(-1 + F('availability_percentage'), output_field=RoundedDecimalField(max_digits=4,decimal_places=3)),
                    ),
                    default=Cast(0,output_field=RoundedDecimalField(max_digits=4,decimal_places=3)),
                    output_field=RoundedDecimalField(max_digits=4,decimal_places=3)
                ),
                discount_value=Case(
                    When(user__activity_type=ActivityType.SERVICE_STATION, 
                        then=Cast(F('item_value') - F('subtotal_value'),output_field=RoundedDecimalField(max_digits=9,decimal_places=2)),
                    ),
                    default=Cast(0, output_field=RoundedDecimalField(max_digits=9,decimal_places=2)),
                    output_field=RoundedDecimalField(max_digits=9,decimal_places=2)
                ),

Но значения еще не округлены в меньшую сторону.

  1. Я попытался воспроизвести тот же запрос, но передавая поле output_field как float и передавая пользовательский валидатор, который анализирует число с помощью функции math.truncate.
  2. .
def truncate(value):
        try:
            return math.trunc(value, 2)
        except:
            return 0

А в аннотации значений я изменил поле ouput_field на:

FloatField(validators[truncate])

Но я тоже не работаю.

Есть ли способ округлить числа в запросе в меньшую сторону? Я не нашел ничего об этом. Родная функция django.db.models.Round не имеет круглого типа в качестве параметра, и я не смог использовать Func() для создания своей собственной... Кто-нибудь может мне помочь?

Вы ограничены определенной версией django? Если нет, то я полагаю, что в документации упоминается аргумент precision в виде ключевого слова для функции Round, который может подойти для ваших нужд. https://docs.djangoproject.com/en/4.1/ref/models/database-functions/#django.db.models.functions.Round

Округляет числовое поле или выражение с точностью (должно быть целое число) до десятичных знаков. По умолчанию округляется до ближайшего целого числа. Округление половинных значений в большую или меньшую сторону зависит от базы данных.

На самом деле я неправильно понял. Вы уже используете точность в одном из ваших примеров, вы просто передаете ее как позиционный аргумент.

Глядя на вашу реализацию с Decimal, я думаю, что у вас правильная идея. Однако, использование prec=2 или prec=3, похоже, относится к точности значащих цифр, поэтому 9809.4069 с prec=2 будет 9800 = 9.8 * 10^2, а prec=3 даст 9810 = 9.81 * 10^3.

Итак, если ваши числа будут floats, вам следует использовать create_decimal, как и раньше, но не ограничивать точность.

context = decimal.Context()
context.create_decimal(9809.4069)

>>> Decimal('9809.468999999999999996023488224')

Тогда вы будете использовать quantize, как описано в документации, для соответствующего отсечения при работе с валютой. https://docs.python.org/3/library/decimal.html

Метод quantize() округляет число до фиксированной экспоненты. Этот метод полезен для денежных приложений, которые часто округляют результаты до фиксированного числа мест

.

Mysql имеет функцию "truncate" для округления значений, полученных из запросов. Вы можете использовать функцию базы данных с 'Truncate' в аргументе функции

from django.db.models import Func,F

Func(F('item_value') * F('availability_percentage'), 2, function='Truncate', output_field=FloatField())
Вернуться на верх