Quant Ledger¶
quant_ledger — дополнительный серверный модуль NodaLogic для хранения количественных остатков и движений. Он подходит для складских остатков, денег, резервов, загрузки ячеек и других задач, где значения изменяются движениями +/-, а текущее состояние должно вычисляться надежно и атомарно.
Модуль не имеет отдельного интерфейса в Designer и используется только из серверных Python-обработчиков.На текущий момент модуль построен на SQLite.
Подключение¶
Обычно достаточно импортировать только используемые функции:
from quant_ledger.api import (
quant, move, transaction,
balance, balances, movements, statement,
NegativeBalanceError,
)
Основные понятия¶
space — логическое пространство учета. Например, детальные складские остатки можно хранить в "stock.detail", а загрузку ячеек — в отдельном "location.load".
quant — полный аналитический ключ остатка. Именно сочетание space + quant определяет одну строку остатка.
Например, для склада полный ключ можно построить из склада, товара, партии и ячейки:
full_quant = quant(warehouse_id, product_id, lot_id, location_id)
selector_quant — дополнительный, более грубый ключ для быстрого отбора группы остатков. Например, все партии и ячейки одного товара на одном складе:
selector = quant(warehouse_id, product_id)
selector_quant не является частью уникального ключа остатка, а используется как отдельный индекс для выборок. Для уже существующего полного quant его менять нельзя.
resources — список числовых ресурсов движения. Поддерживается до 16 ресурсов. В складском примере ресурс 0 обычно означает количество, ресурс 1 — сумму:
[quantity, amount]
Значения возвращаются как Decimal. Внутри SQL они хранятся как fixed-point с точностью 6 знаков после запятой.
Простое движение¶
Функция move() одновременно записывает движение и изменяет остаток. Операция атомарна: движение и остаток либо сохраняются вместе, либо не сохраняются вовсе.
Пример прихода:
result = move(
"stock.detail",
quant(warehouse_id, product_id, lot_id, location_id),
document_date,
f"{document_id}:receipt",
{
"document": document_id,
"employee": employee_id,
"comment": "Приход товара",
},
[quantity, amount],
selector_quant=quant(warehouse_id, product_id),
allow_negative=False,
)
Параметры:
space — пространство учета;
quant — полный аналитический ключ;
period — дата/время движения;
operation_id — стабильный идентификатор логического движения;
details — произвольные данные движения для истории и фильтрации;
resources — дельты ресурсов;
selector_quant — ключ быстрого группового отбора;
allow_negative — разрешать ли отрицательный ресурс
0.
Для расхода достаточно передать отрицательную дельту:
move(
"stock.detail",
full_quant,
document_date,
f"{document_id}:expense",
{"document": document_id},
[-quantity, -amount],
selector_quant=selector,
allow_negative=False,
)
По умолчанию отрицательный ресурс 0 запрещен. При нехватке остатка возникает NegativeBalanceError:
try:
move(...)
except NegativeBalanceError as exc:
Message("Недостаточно остатка: " + str(exc.attempted))
Если нужно запретить отрицательные значения сразу для нескольких ресурсов, можно указать их индексы явно:
move(..., nonnegative_resources=[0, 1])
Повторное проведение¶
operation_id должен быть стабильным для одного логического движения. Если move() вызывается повторно с тем же operation_id, старое движение не дублируется и не игнорируется — оно заменяется новым в одной транзакции.
result = move(
"stock.detail",
full_quant,
document_date,
f"{document_id}:receipt",
{"document": document_id},
[new_quantity, new_amount],
selector_quant=selector,
)
if result.reposted:
Message("Документ перепроведен")
При перепроведении могут измениться период, полный quant, selector_quant, details и ресурсы. Если новый вариант приводит к ошибке, например к отрицательному остатку, операция откатывается и прежнее движение остается без изменений.
Несколько движений одной операцией¶
Если бизнес-операция состоит из нескольких движений, их следует объединять через transaction(). Типичный пример — перемещение между ячейками: расход с одной ячейки и приход на другую должны пройти только вместе.
source_quant = quant(warehouse_id, product_id, lot_id, source_location_id)
target_quant = quant(warehouse_id, product_id, lot_id, target_location_id)
selector = quant(warehouse_id, product_id)
with transaction() as tx:
tx.move(
"stock.detail",
source_quant,
document_date,
f"{document_id}:source",
{"document": document_id},
[-quantity],
selector_quant=selector,
allow_negative=False,
)
tx.move(
"stock.detail",
target_quant,
document_date,
f"{document_id}:target",
{"document": document_id},
[quantity],
selector_quant=selector,
allow_negative=False,
)
Если первое или второе движение не может быть выполнено, откатывается вся транзакция. Для разных движений одной операции используются разные operation_id, например :source и :target.
Чтение текущих остатков¶
balance(space, quant) — получить точный остаток по полному ключу:
row = balance("stock.detail", full_quant)
qty = row.resources[0]
amount = row.resources[1]
Если остатка еще нет, возвращается строка с нулевыми ресурсами.
balances(…) — выбрать несколько остатков. Наиболее эффективный вариант — отбор по selector_quant:
rows = balances(
"stock.detail",
selector_quant=quant(warehouse_id, product_id),
positive_resource=0,
)
for row in rows:
warehouse, product, lot, location = row.parts
qty = row.resources[0]
Полезные параметры balances():
selector_quant — быстрый отбор по селектору;
quants — набор точных полных квантов;
nonzero_resource — оставить строки, где указанный ресурс не равен нулю;
positive_resource — оставить строки с положительным ресурсом;
limit — ограничить количество строк.
История движений¶
movements() возвращает историю движений и позволяет фильтровать ее по периоду, quant, selector_quant, operation_id и полям details.
rows = movements(
"stock.detail",
period_from=date_from,
period_to=date_to,
details={"document": document_id},
)
for row in rows:
print(row.period, row.resources, row.details)
details предназначен для аудита и точной фильтрации движений. Например, в нем удобно хранить UID документа, сотрудника, тип операции или комментарий.
Ведомость за период¶
Для отчетов вида «Начальный остаток / Приход / Расход / Конечный остаток» используется statement(). Нет необходимости загружать все движения и пересчитывать итоги вручную.
rows = statement(
"stock.detail",
period_from=date_from,
period_to=date_to,
selector_quant=quant(warehouse_id, product_id),
)
for row in rows:
opening = row.opening[0]
income = row.income[0]
expense = row.expense[0]
closing = row.closing[0]
Каждая строка StatementRow относится к одному полному quant. Если отчету нужен более высокий уровень, например только склад + товар, строки следует агрегировать в Python после SQL-выборки.
FEFO и другие бизнес-сортировки¶
В quant находятся только ключи, а не поля связанных узлов. Поэтому SQL не может, например, сортировать партии по Lot.expire_date, если в кванте хранится только UID партии.
Правильный подход для FEFO:
rows = balances(
"stock.detail",
selector_quant=quant(warehouse_id, product_id),
positive_resource=0,
)
lot_ids = {str(row.parts[2]) for row in rows}
lots = Lot.get_many_data(lot_ids)
rows.sort(
key=lambda row: str(
(lots.get(str(row.parts[2])) or {}).get("expire_date") or "9999-12-31"
)
)
Сначала SQL быстро отбирает кандидатов по selector_quant, затем связанные узлы загружаются пакетно и бизнес-сортировка выполняется в Python.
Работа с quant¶
quant() создает канонический обратимый ключ. Разбирать его вручную по символу | не нужно.
q = quant(warehouse_id, product_id, lot_id, location_id)
parts = parse_quant(q)
lot_id = quant_part(q, 2)
Для этого при необходимости импортируются parse_quant и quant_part.
Если в вашей модели используется специальное значение "~" для пустого измерения, оно является обычным точным значением, а не wildcard:
q = quant(warehouse_id, product_id, "~", location_id)
Проверка и восстановление¶
verify_space(space) сравнивает сохраненные остатки с суммой движений:
from quant_ledger.api import verify_space
result = verify_space("stock.detail")
if not result.valid:
print(result.errors)
rebuild_balances(space) полностью перестраивает остатки пространства по истории движений и затем выполняет проверку:
from quant_ledger.api import rebuild_balances
result = rebuild_balances("stock.detail")
Эти функции предназначены для диагностики и восстановления, а не для обычного проведения документов.
Scope конфигурации¶
В серверном обработчике NodaLogic scope текущей конфигурации определяется автоматически, поэтому обычно передавать его не требуется.
При использовании модуля вне серверного обработчика scope нужно задать явно:
row = balance("stock.detail", full_quant, scope=config_uid)
Данные разных конфигураций с одинаковыми space и quant остаются изолированными друг от друга.
Практические правила¶
Не изменяйте SQL-таблицы остатков напрямую — проводите только дельты через
move().Не вычисляйте новый остаток в приложении по схеме «прочитать + изменить + сохранить».
Используйте стабильный
operation_idдля перепроведения документа.Используйте
transaction()для операций, которые должны проводиться целиком.Для быстрых групповых выборок заранее продумайте
selector_quant.Не меняйте
selector_quantдля уже существующего полногоquant.Для текущего остатка используйте
balance()/balances(), для истории —movements(), для периодической ведомости —statement().Не вызывайте
quant_ledgerиз Android-обработчиков — модуль предназначен только для сервера.