Как аннотировать измененную таблицу связанных объектов в Django

У меня есть небольшое бизнес-приложение, которое можно упростить в виде следующих моделей:

class Client(..):
    name = CharField(...)

class Sale(..):
    client = ForeignKey(Client, ...)
    item = CharField(...)
    time = DateTimeField(...)
    value = DecimalField(...)

class Receive(..):
    client = ForeignKey(Client, ...)
    time = DateTimeField(...)
    value = DecimalField(...)

Теперь мне нужен список клиентов, отображающий всех клиентов с общим объемом продаж, платежами, стоимостью дебиторской задолженности и самой ранней датой неоплаченных продаж ("edsu"), которую не может покрыть платеж.

Например, Клиент A

  • 2022-01-01: купил предмет X за $10,
  • 2022-02-15: купил предмет Y за $15,
  • 2022-02-25: купил предмет X за $10, и
  • 2022-03-10: заплатил $12.

Тогда клиент А имеет дебиторскую задолженность в размере $23 и edsu на 2022-02-15.

До сих пор я использовал сырой sql (как показано ниже) для выполнения запроса, и он работает хорошо.

def client_list_view(request):
    ...
    clients = Client.objects.raw(
        raw_query = '''WITH app_balance AS (
            SELECT id, client_id, item, time, val,
                SUM(sale) OVER wCN0 - SUM(receive) OVER wClt AS unpaid
            FROM (
                SELECT id, client_id, item, time, val AS sale, 0 AS receive
                    FROM app_sale
                UNION SELECT id, client_id, '' AS item, time, 0 AS sale, val AS receive
                    FROM app_receive
            ) app_balance
            WHERE time < %(te)s::TIMESTAMPTZ
            WINDOW wClt (PARTITION BY client_id),
               wCN0 (wClt ORDER BY time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
            ORDER BY time
        )
        SELECT id, name, sales, receive, receivable, edsu
        FROM app_client LEFT JOIN (
            SELECT client_id, 
                SUM(sale) FILTER (WHERE time > %(te)s::TIMESTAMPTZ) AS sales,
                SUM(receive) FILTER (WHERE time > %(te)s::TIMESTAMPTZ) AS receive,
                SUM(sale-receive) AS receivable, 
                MIN(time) FILTER (WHERE unpaid > 0) AS edsu
            FROM app_balance
            GROUP BY client_id
        ) client_balance ON client_balance.client_id = app_client.id;''', 
        params = {'ts': time_start.isoformat(), 'te': time_end.isoformat()}
    )
    return render(request, 'app/clients.html', {'clients': clients})     

Однако, по мере роста приложения, его становится все труднее поддерживать, что подталкивает меня к решению отказаться от raw sql и перейти на Django-стилизованный QuerySet, начиная со следующего SQL представления

CREATE VIEW app_balance_view AS 
    SELECT
        id, 1 AS cat, client_id, item, time, val AS sale, 0 AS rcv
        FROM app_sale
    UNION SELECT
        id, 2 AS cat, client_id, '' AS item, time, 0 AS sale, val AS rcv
        FROM app_receive;

и неуправляемая модель

class Balance(..):
    cat = IntegerField(choices=CATS, ...)
    client = ForeignKey(Client, ...)
    item = CharField(...)
    time = DateTimeField(...)
    sale = DecimalField(...)
    rcv = DecimalField(...)
    class Meta:
        managed = False
        db_table = 'app_balance_view'

Я застрял здесь, не найдя способа аннотировать клиента с пользовательским балансом QuerySet возможно следующим образом:

balance = Balance.objects.filter(time__lt=time_end).annotate(
    total_sale = Window(Sum('sale'), partition_by=F('client_id')),
    cur_paid = Window(Sum('rcv'), partition_by=F('client_id'), frame=RowRange(None,0))
).annotate(unpaid = F('total_sale') - F('cur_paid'))

настолько, что я могу сделать

clients = Client.objects.annotate(
    edsu = Min('balance__time', filter=Q(balance__unpaid__gt=0)),
)

Если вышеуказанное невозможно (или не рекомендуется), что может быть альтернативой для достижения этой цели?

Окружающая среда:

  • python == 3.10.2
  • Django == 4.0.1
  • PostgreSQL == 14

Я нашел решение, используя следующую функцию представления:

CREATE VIEW app_bal_view AS
    SELECT 
        id, cat, client_id, time, item, sale, rcv,
        SUM(sale) OVER wcn0 - SUM(rcv) OVER wclt AS unpaid
        FROM (
            SELECT
                id, 1 AS cat, client_id, time, item, value AS sale, 0 AS rcv
                FROM app_sale
            UNION SELECT
                id, 2 AS cat, client_id, time, ‘’ AS item, 0 AS sale, value AS rcv
                FROM app_receive
        ) app_balance_tmp
        WINDOW wclt AS (PARTITION BY client_id),
            wcn0 AS (wclt ORDER BY time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

добавление поля unpaid к модели Balance

class Balance(..):
    ..
    unpaid = DecimalField(..)

и используя тот же клиентский QuerySet, что и в Question.

Вернуться на верх