funora 0.0.1.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Смена валюты показа и разбор ответа площадки.
|
|
2
|
+
|
|
3
|
+
ЧТО ДЕЛАЕТ ЭТА ОПЕРАЦИЯ И ЧЕМ ОПАСНА. Она меняет валюту, в которой площадка
|
|
4
|
+
показывает суммы, - и меняет ГЛОБАЛЬНО: после неё каждая страница отдаёт другие
|
|
5
|
+
числа.
|
|
6
|
+
|
|
7
|
+
Дороже всего это для снимков рынка. Сравнение двух снимков, снятых по разные
|
|
8
|
+
стороны от смены, объявит сменившейся КАЖДУЮ цену - без единой ошибки, без строки
|
|
9
|
+
в журнале, без следа.
|
|
10
|
+
|
|
11
|
+
ДВЕ ВЕТКИ ОТВЕТА, И РАЗЛИЧАТЬ ИХ ОБЯЗАТЕЛЬНО. Либо валюта сменена сразу, либо
|
|
12
|
+
возвращается окно подтверждения - и тогда смены НЕ БЫЛО.
|
|
13
|
+
|
|
14
|
+
ПОДТВЕРЖДЕНИЕ НЕ ДАЁТСЯ ЗА ПОЛЬЗОВАТЕЛЯ. В запросе есть поле подтверждения, и
|
|
15
|
+
сюда всегда уходит отрицание. Вторая ветка отдаётся исходом, а не проглатывается:
|
|
16
|
+
решать, соглашаться ли, вправе только человек.
|
|
17
|
+
|
|
18
|
+
КУРС ИЗ ОКНА НЕ РАЗБИРАЕТСЯ. Сторонняя реализация достаёт его регулярным
|
|
19
|
+
выражением из абзаца окна - то есть из ТЕКСТА НА ЛОКАЛИ ИНТЕРФЕЙСА. Смена языка
|
|
20
|
+
аккаунта ломает такой разбор молча, а локаль привязана к аккаунту, а не к адресу.
|
|
21
|
+
|
|
22
|
+
Текст окна отдаётся как есть.
|
|
23
|
+
|
|
24
|
+
Наблюдено нами: переключатель в шапке, пункты которого несут код ISO 4217.
|
|
25
|
+
Известно от FunPayAPI (FunPayCardinal, account.py, get_exchange_rate): адрес,
|
|
26
|
+
имена полей запроса и ключи ответа.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from typing import Any, Final
|
|
34
|
+
|
|
35
|
+
from ._observed import Observed
|
|
36
|
+
from .errors import ProtocolChangedError
|
|
37
|
+
|
|
38
|
+
__all__ = ["CurrencySwitch", "parse_currency_switch", "SWITCH_CURRENCY_PATH", "NEVER_CONFIRMED"]
|
|
39
|
+
|
|
40
|
+
#: Адрес смены валюты. Известен от сторонней реализации.
|
|
41
|
+
SWITCH_CURRENCY_PATH: Final[str] = "/account/switchCurrency"
|
|
42
|
+
|
|
43
|
+
#: Что уходит в поле подтверждения. ВСЕГДА отрицание.
|
|
44
|
+
#:
|
|
45
|
+
#: Стоит константой, а не литералом в сборке запроса, чтобы согласие за
|
|
46
|
+
#: пользователя нельзя было дать случайной правкой одного знака. Сторонняя
|
|
47
|
+
#: реализация шлёт то же самое и никогда иного.
|
|
48
|
+
NEVER_CONFIRMED: Final[str] = "false"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class CurrencySwitch:
|
|
53
|
+
"""Исход смены валюты показа.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
switched (bool): Сменила ли площадка валюту. Ложь при возвращённом окне
|
|
57
|
+
означает, что смены не было.
|
|
58
|
+
confirmation_required (bool): Вернула ли площадка окно подтверждения.
|
|
59
|
+
Истина означает: смены НЕ БЫЛО, и площадка хочет согласия. Давать
|
|
60
|
+
его вправе только человек.
|
|
61
|
+
confirmation_text (Observed[str]): Текст окна, КАК ЕСТЬ. Не разбирается:
|
|
62
|
+
внутри лежит курс обмена, и вывести его можно только из текста на
|
|
63
|
+
локали интерфейса.
|
|
64
|
+
requested (str): Код валюты, о котором просили.
|
|
65
|
+
observed_at (datetime): Момент получения ответа.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
switched: bool
|
|
69
|
+
confirmation_required: bool
|
|
70
|
+
confirmation_text: Observed[str]
|
|
71
|
+
requested: str
|
|
72
|
+
observed_at: datetime
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_currency_switch(payload: Any, *, requested: str, observed_at: datetime) -> CurrencySwitch:
|
|
76
|
+
"""Разбирает ответ площадки на смену валюты.
|
|
77
|
+
|
|
78
|
+
ВЕТКА «СМЕНЕНО» УЗНАЁТСЯ ПОЛОЖИТЕЛЬНО: ключ адреса присутствует и пуст.
|
|
79
|
+
Присутствие непустого адреса означает не успех, а что-то третье, о чём мы
|
|
80
|
+
ничего не знаем, - и объявлять успехом его нельзя.
|
|
81
|
+
|
|
82
|
+
Аргументы:
|
|
83
|
+
payload (Any): Разобранное тело ответа.
|
|
84
|
+
requested (str): Код валюты, о котором просили.
|
|
85
|
+
observed_at (datetime): Момент получения.
|
|
86
|
+
|
|
87
|
+
Возвращает:
|
|
88
|
+
CurrencySwitch: Исход.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ProtocolChangedError: Если ответ непригоден для чтения.
|
|
92
|
+
"""
|
|
93
|
+
if not isinstance(payload, dict):
|
|
94
|
+
raise ProtocolChangedError(f"ответ смены валюты не объект, а {type(payload).__name__}")
|
|
95
|
+
|
|
96
|
+
raw_modal = payload.get("modal")
|
|
97
|
+
if isinstance(raw_modal, str) and raw_modal.strip():
|
|
98
|
+
# Окно есть - значит смены НЕ БЫЛО. Подтверждать за пользователя мы не
|
|
99
|
+
# станем, и потому это конечный исход, а не промежуточный шаг.
|
|
100
|
+
return CurrencySwitch(
|
|
101
|
+
switched=False,
|
|
102
|
+
confirmation_required=True,
|
|
103
|
+
confirmation_text=Observed.present(raw_modal),
|
|
104
|
+
requested=requested,
|
|
105
|
+
observed_at=observed_at,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if "url" not in payload:
|
|
109
|
+
raise ProtocolChangedError(
|
|
110
|
+
"в ответе смены валюты нет ни окна подтверждения, ни ключа адреса. "
|
|
111
|
+
"Сменилась валюта или нет - неизвестно, а угадывать здесь дорого: "
|
|
112
|
+
"после смены каждая страница отдаёт другие числа"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
raw_url = payload["url"]
|
|
116
|
+
# Пустой адрес - положительный признак того, что делать больше нечего.
|
|
117
|
+
# Непустой означает что-то третье; успехом его объявлять нельзя.
|
|
118
|
+
if isinstance(raw_url, str) and raw_url.strip():
|
|
119
|
+
raise ProtocolChangedError(
|
|
120
|
+
f"ответ смены валюты несёт непустой адрес {raw_url!r}. Наблюдались "
|
|
121
|
+
"две ветки - смена и окно подтверждения, - и эта не из них"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return CurrencySwitch(
|
|
125
|
+
switched=True,
|
|
126
|
+
confirmation_required=False,
|
|
127
|
+
confirmation_text=Observed.missing("no_confirmation_window"),
|
|
128
|
+
requested=requested,
|
|
129
|
+
observed_at=observed_at,
|
|
130
|
+
)
|
funora/_cursor.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Переносимая позиция пагинации, привязанная к операции и её владельцу.
|
|
2
|
+
|
|
3
|
+
Это формат хранения, а не подпись или разрешение доступа. Проверка сессии
|
|
4
|
+
по-прежнему принадлежит транспорту. Сырые значения кодируются отдельно:
|
|
5
|
+
каноническая нормализация JSON не должна менять непрозрачный токен сервера.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from ._canonical import canonical_dumps
|
|
14
|
+
from .contract import (
|
|
15
|
+
ACCEPTED_CURSOR_FORMAT_VERSIONS,
|
|
16
|
+
ADAPTER_FAMILY,
|
|
17
|
+
CANONICAL_FORM_VERSION,
|
|
18
|
+
CURSOR_FORMAT_VERSION,
|
|
19
|
+
MAX_CURSOR_BYTES,
|
|
20
|
+
)
|
|
21
|
+
from .errors import CursorIncompatibleError, ValidationError
|
|
22
|
+
|
|
23
|
+
_KINDS = {"chats.history_before", "reviews.get"}
|
|
24
|
+
_KEYS = {"version", "canonical_form_version", "adapter_family", "kind", "owner", "position"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pack(raw: bytes) -> str:
|
|
28
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _unpack(value: str) -> bytes:
|
|
32
|
+
if not isinstance(value, str) or not value or len(value) > MAX_CURSOR_BYTES:
|
|
33
|
+
raise ValueError("cursor size or type")
|
|
34
|
+
raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
|
35
|
+
if _pack(raw) != value:
|
|
36
|
+
raise ValueError("non-canonical base64url")
|
|
37
|
+
return raw
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _envelope(
|
|
41
|
+
kind: str,
|
|
42
|
+
owner: str,
|
|
43
|
+
position: str,
|
|
44
|
+
scope: str | None,
|
|
45
|
+
*,
|
|
46
|
+
version: int = CURSOR_FORMAT_VERSION,
|
|
47
|
+
) -> dict[str, str | int | None]:
|
|
48
|
+
if kind not in _KINDS:
|
|
49
|
+
raise ValueError("cursor kind")
|
|
50
|
+
if not isinstance(owner, str) or not owner or len(owner) > MAX_CURSOR_BYTES:
|
|
51
|
+
raise ValueError("cursor owner")
|
|
52
|
+
if not isinstance(position, str) or not position.strip() or len(position) > MAX_CURSOR_BYTES:
|
|
53
|
+
raise ValueError("cursor position")
|
|
54
|
+
if scope is not None and (
|
|
55
|
+
not isinstance(scope, str) or not scope or len(scope) > MAX_CURSOR_BYTES
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("cursor scope")
|
|
58
|
+
envelope: dict[str, str | int | None] = {
|
|
59
|
+
"version": version,
|
|
60
|
+
"canonical_form_version": CANONICAL_FORM_VERSION,
|
|
61
|
+
"adapter_family": ADAPTER_FAMILY,
|
|
62
|
+
"kind": kind,
|
|
63
|
+
"owner": _pack(owner.encode("utf-8")),
|
|
64
|
+
"position": _pack(position.encode("utf-8")),
|
|
65
|
+
}
|
|
66
|
+
if version == 2:
|
|
67
|
+
envelope["scope"] = None if scope is None else _pack(scope.encode("utf-8"))
|
|
68
|
+
return envelope
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def encode_cursor(kind: str, owner: str, position: str, *, scope: str | None = None) -> str:
|
|
72
|
+
"""Сохраняет позицию без нормализации её байтов."""
|
|
73
|
+
try:
|
|
74
|
+
token = _pack(canonical_dumps(_envelope(kind, owner, position, scope)).encode("utf-8"))
|
|
75
|
+
if len(token) > MAX_CURSOR_BYTES:
|
|
76
|
+
raise ValueError("cursor too large")
|
|
77
|
+
return token
|
|
78
|
+
except (TypeError, ValueError, ValidationError):
|
|
79
|
+
raise CursorIncompatibleError(
|
|
80
|
+
"позиция не помещается в поддерживаемый формат курсора"
|
|
81
|
+
) from None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decode_cursor(token: str, *, kind: str) -> tuple[str, str, str | None]:
|
|
85
|
+
"""Возвращает владельца, позицию и область выборки; v1 читается без области."""
|
|
86
|
+
try:
|
|
87
|
+
raw = _unpack(token)
|
|
88
|
+
payload = json.loads(raw)
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise ValueError("cursor envelope")
|
|
91
|
+
version = payload.get("version")
|
|
92
|
+
if (
|
|
93
|
+
type(version) is not int
|
|
94
|
+
or version not in ACCEPTED_CURSOR_FORMAT_VERSIONS
|
|
95
|
+
or set(payload) != (_KEYS | {"scope"} if version == 2 else _KEYS)
|
|
96
|
+
or type(payload["canonical_form_version"]) is not int
|
|
97
|
+
or payload["canonical_form_version"] != CANONICAL_FORM_VERSION
|
|
98
|
+
or payload["adapter_family"] != ADAPTER_FAMILY
|
|
99
|
+
or payload["kind"] != kind
|
|
100
|
+
):
|
|
101
|
+
raise ValueError("cursor incompatible")
|
|
102
|
+
owner = _unpack(payload["owner"]).decode("utf-8")
|
|
103
|
+
position = _unpack(payload["position"]).decode("utf-8")
|
|
104
|
+
scope = payload.get("scope")
|
|
105
|
+
if scope is not None:
|
|
106
|
+
scope = _unpack(scope).decode("utf-8")
|
|
107
|
+
# Сверка отвергает дубликаты ключей, лишние пробелы и альтернативные
|
|
108
|
+
# кодировки: один курсор имеет одно представление между реализациями.
|
|
109
|
+
if (
|
|
110
|
+
canonical_dumps(_envelope(kind, owner, position, scope, version=version)).encode(
|
|
111
|
+
"utf-8"
|
|
112
|
+
)
|
|
113
|
+
!= raw
|
|
114
|
+
):
|
|
115
|
+
raise ValueError("non-canonical envelope")
|
|
116
|
+
return owner, position, scope
|
|
117
|
+
except (TypeError, ValueError, RecursionError, ValidationError):
|
|
118
|
+
raise CursorIncompatibleError(
|
|
119
|
+
"курсор повреждён либо относится к другому формату или операции"
|
|
120
|
+
) from None
|
funora/_delivered.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Реестр выданного: что уже отправлено покупателю и по какому заказу.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ ОТДЕЛЬНЫЙ РЕЕСТР, КОГДА ЕСТЬ КУРСОР ЗАКАЗОВ. Курсор защитой не является, и
|
|
4
|
+
это установлено, а не предположено. Три причины, каждая записана в самом коде:
|
|
5
|
+
|
|
6
|
+
Курсор снимается только с полного чтения, а события по прочитанным строкам
|
|
7
|
+
порождаются и при неполном. Заказ, выпавший из неполного чтения, в курсор не
|
|
8
|
+
попадёт и в следующий раз придёт как новый.
|
|
9
|
+
|
|
10
|
+
Обрыв тела на передаче по частям даёт правдоподобное «полно» с недостачей строк.
|
|
11
|
+
Выпавшие заказы уходят из курсора и возвращаются событием о создании.
|
|
12
|
+
|
|
13
|
+
Гашение повторов живёт по сроку и гасит по отпечатку события. «Этот заказ
|
|
14
|
+
выдан» обязано жить, пока жив заказ, а не пока не истёк срок.
|
|
15
|
+
|
|
16
|
+
ЗАПИСЬ ИДЁТ ВПЕРЕДИ ОТПРАВКИ. Тот же приём и тот же довод, что у реестра
|
|
17
|
+
отправок: «не засчитаем, раз не подтвердилось» означает не считать ровно те
|
|
18
|
+
выдачи, которые могли уйти. Повторная выдача необратима.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Final
|
|
25
|
+
|
|
26
|
+
from .errors import StateSchemaIncompatibleError
|
|
27
|
+
|
|
28
|
+
__all__ = ["Delivery", "DeliveryLedger", "QUEUED_OUTCOME"]
|
|
29
|
+
|
|
30
|
+
#: Исход, с которым запись о выдаче заводится.
|
|
31
|
+
#:
|
|
32
|
+
#: Означает «задание поставлено в очередь, чем кончилось - ещё не известно».
|
|
33
|
+
#: Запись, оставшаяся с ним после разбора очереди, - повод посмотреть заказ
|
|
34
|
+
#: глазами: см. :meth:`DeliveryLedger.unsettled`.
|
|
35
|
+
QUEUED_OUTCOME: Final[str] = "queued"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Delivery:
|
|
40
|
+
"""Запись о выдаче.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
order_id (str): Заказ, по которому выдано.
|
|
44
|
+
offer_id (str): Предложение, которое сочли выданным. Пустая строка
|
|
45
|
+
означает, что выдавал человек и предложение не устанавливалось.
|
|
46
|
+
at_ms (int): Момент выдачи по стенным часам.
|
|
47
|
+
outcome (str): Исход отправки, каким его вернул канал.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
order_id: str
|
|
51
|
+
offer_id: str
|
|
52
|
+
at_ms: int
|
|
53
|
+
outcome: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DeliveryLedger:
|
|
57
|
+
"""Что уже выдано.
|
|
58
|
+
|
|
59
|
+
Реестр не забывает записи по сроку НАМЕРЕННО, в отличие от реестра
|
|
60
|
+
отправок. Тот считает часовые пределы, и старая запись ему не нужна; этот
|
|
61
|
+
отвечает на вопрос «выдавали ли по этому заказу», и ответ на него не
|
|
62
|
+
устаревает никогда.
|
|
63
|
+
|
|
64
|
+
Растёт он на запись за заказ. Продавец с сотней заказов в день накопит за
|
|
65
|
+
год тридцать тысяч записей - несколько мегабайт, и это дешевле любой
|
|
66
|
+
повторной выдачи.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
__slots__ = ("_done",)
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self._done: dict[str, Delivery] = {}
|
|
73
|
+
|
|
74
|
+
def seen(self, order_id: str) -> bool:
|
|
75
|
+
"""Говорит, выдавали ли уже по этому заказу.
|
|
76
|
+
|
|
77
|
+
Аргументы:
|
|
78
|
+
order_id (str): Заказ.
|
|
79
|
+
|
|
80
|
+
Возвращает:
|
|
81
|
+
bool: True, если запись о выдаче есть.
|
|
82
|
+
"""
|
|
83
|
+
return order_id in self._done
|
|
84
|
+
|
|
85
|
+
def get(self, order_id: str) -> Delivery | None:
|
|
86
|
+
"""Возвращает запись о выдаче.
|
|
87
|
+
|
|
88
|
+
Аргументы:
|
|
89
|
+
order_id (str): Заказ.
|
|
90
|
+
|
|
91
|
+
Возвращает:
|
|
92
|
+
Delivery | None: Запись либо None.
|
|
93
|
+
"""
|
|
94
|
+
return self._done.get(order_id)
|
|
95
|
+
|
|
96
|
+
def record(self, delivery: Delivery) -> None:
|
|
97
|
+
"""Записывает выдачу.
|
|
98
|
+
|
|
99
|
+
Перезаписи НЕТ: первая запись о заказе побеждает. Вторая означала бы,
|
|
100
|
+
что мы выдали дважды, и затирать след первой выдачи значило бы прятать
|
|
101
|
+
именно то, ради чего реестр заведён.
|
|
102
|
+
|
|
103
|
+
Аргументы:
|
|
104
|
+
delivery (Delivery): Запись о выдаче.
|
|
105
|
+
|
|
106
|
+
Возвращает:
|
|
107
|
+
None
|
|
108
|
+
"""
|
|
109
|
+
self._done.setdefault(delivery.order_id, delivery)
|
|
110
|
+
|
|
111
|
+
def settle(self, order_id: str, outcome: str) -> None:
|
|
112
|
+
"""Проставляет записи настоящий исход отправки.
|
|
113
|
+
|
|
114
|
+
ЗАПИСЬ ЗАВОДИТСЯ ИСХОДОМ ``queued`` И ПРЕЖДЕ ТАК И ОСТАВАЛАСЬ. Поле
|
|
115
|
+
объявлялось «исход отправки, каким его вернул канал», а канал к нему не
|
|
116
|
+
притрагивался никто: успешная выдача и выдача, потерянная между записью
|
|
117
|
+
и отправкой, лежали в реестре одинаковыми.
|
|
118
|
+
|
|
119
|
+
Отсюда и был вред: найти потерянные было нечем. Проверка «выдавали ли»
|
|
120
|
+
на исход не смотрит и смотреть не должна - запись о заказе означает
|
|
121
|
+
«больше не выдавать», и это верно при любом исходе. Но человеку,
|
|
122
|
+
который разбирается, чем кончился день, нужно уметь их различать.
|
|
123
|
+
|
|
124
|
+
Записи нет - ничего не происходит: settle не заводит записей. Завести
|
|
125
|
+
её здесь значило бы объявить выданным заказ, по которому решения не
|
|
126
|
+
принимали.
|
|
127
|
+
|
|
128
|
+
Аргументы:
|
|
129
|
+
order_id (str): Заказ.
|
|
130
|
+
outcome (str): Исход, каким его вернул канал, либо имя отказа.
|
|
131
|
+
|
|
132
|
+
Возвращает:
|
|
133
|
+
None
|
|
134
|
+
"""
|
|
135
|
+
existing = self._done.get(order_id)
|
|
136
|
+
if existing is None:
|
|
137
|
+
return
|
|
138
|
+
self._done[order_id] = Delivery(
|
|
139
|
+
order_id=existing.order_id,
|
|
140
|
+
offer_id=existing.offer_id,
|
|
141
|
+
at_ms=existing.at_ms,
|
|
142
|
+
outcome=outcome,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def unsettled(self) -> tuple[str, ...]:
|
|
146
|
+
"""Перечисляет заказы, у которых исход так и остался ``queued``.
|
|
147
|
+
|
|
148
|
+
ЭТО И ЕСТЬ СПИСОК ПОДОЗРИТЕЛЬНЫХ. Задание поставлено в очередь, а чем
|
|
149
|
+
кончилась отправка, реестр не узнал: процесс мог умереть между записью и
|
|
150
|
+
разбором очереди. Товар при этом покупателю мог не уйти, а повторно он
|
|
151
|
+
не уйдёт уже никогда - запись о заказе стоит.
|
|
152
|
+
|
|
153
|
+
Звать стоит при запуске: заказы отсюда - те, по которым стоит посмотреть
|
|
154
|
+
переписку глазами.
|
|
155
|
+
|
|
156
|
+
Возвращает:
|
|
157
|
+
tuple[str, ...]: Заказы с незакрытым исходом, в порядке записи.
|
|
158
|
+
"""
|
|
159
|
+
return tuple(
|
|
160
|
+
order_id for order_id, one in self._done.items() if one.outcome == QUEUED_OUTCOME
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def snapshot(self) -> dict[str, Any]:
|
|
164
|
+
"""Отдаёт состояние обычными значениями для файла состояния.
|
|
165
|
+
|
|
166
|
+
Возвращает:
|
|
167
|
+
dict[str, Any]: Состояние, пригодное для записи в файл.
|
|
168
|
+
"""
|
|
169
|
+
return {
|
|
170
|
+
"done": [
|
|
171
|
+
{
|
|
172
|
+
"order_id": one.order_id,
|
|
173
|
+
"offer_id": one.offer_id,
|
|
174
|
+
"at_ms": one.at_ms,
|
|
175
|
+
"outcome": one.outcome,
|
|
176
|
+
}
|
|
177
|
+
for one in self._done.values()
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
def restore(self, payload: dict[str, Any]) -> None:
|
|
182
|
+
"""Восстанавливает реестр целиком после проверки всех записей.
|
|
183
|
+
|
|
184
|
+
Повреждение даёт StateSchemaIncompatibleError и сохраняет прежний
|
|
185
|
+
реестр. Пропущенная выдача разрешила бы выдать тот же заказ повторно.
|
|
186
|
+
Отсутствующий раздел старого файла означает пустой список; отсутствующие
|
|
187
|
+
offer_id и outcome остаются пустыми строками, сохраняя защиту по order_id.
|
|
188
|
+
"""
|
|
189
|
+
if not isinstance(payload, dict):
|
|
190
|
+
raise StateSchemaIncompatibleError("реестр выдач должен быть объектом")
|
|
191
|
+
raw = payload.get("done", [])
|
|
192
|
+
if not isinstance(raw, list):
|
|
193
|
+
raise StateSchemaIncompatibleError("выдачи должны быть списком записей")
|
|
194
|
+
|
|
195
|
+
restored: dict[str, Delivery] = {}
|
|
196
|
+
for one in raw:
|
|
197
|
+
if not isinstance(one, dict):
|
|
198
|
+
raise StateSchemaIncompatibleError("непригодная запись выдачи")
|
|
199
|
+
|
|
200
|
+
order_id = one.get("order_id")
|
|
201
|
+
# Только строка и только непустая. Число, None и словарь дали бы
|
|
202
|
+
# ключ вида 'None' либо "{'a': 1}" - запись о заказе, которого нет.
|
|
203
|
+
if not isinstance(order_id, str) or not order_id.strip():
|
|
204
|
+
raise StateSchemaIncompatibleError("непригодный идентификатор выданного заказа")
|
|
205
|
+
|
|
206
|
+
at_ms = one.get("at_ms")
|
|
207
|
+
# Логическое исключается отдельно: в Python истина - это единица, и
|
|
208
|
+
# метка времени True прочиталась бы как первая миллисекунда эпохи.
|
|
209
|
+
if isinstance(at_ms, bool) or not isinstance(at_ms, int):
|
|
210
|
+
raise StateSchemaIncompatibleError("непригодная метка времени выдачи")
|
|
211
|
+
|
|
212
|
+
offer_id = one.get("offer_id", "")
|
|
213
|
+
outcome = one.get("outcome", "")
|
|
214
|
+
if not isinstance(offer_id, str) or not isinstance(outcome, str):
|
|
215
|
+
raise StateSchemaIncompatibleError("непригодное предложение или исход выдачи")
|
|
216
|
+
if order_id in restored:
|
|
217
|
+
raise StateSchemaIncompatibleError("повтор заказа в реестре выдач")
|
|
218
|
+
restored[order_id] = Delivery(
|
|
219
|
+
order_id=order_id,
|
|
220
|
+
offer_id=offer_id,
|
|
221
|
+
at_ms=at_ms,
|
|
222
|
+
outcome=outcome,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
self._done = restored
|
|
226
|
+
|
|
227
|
+
def __len__(self) -> int:
|
|
228
|
+
"""Возвращает число записей.
|
|
229
|
+
|
|
230
|
+
Возвращает:
|
|
231
|
+
int: Сколько заказов уже выдано.
|
|
232
|
+
"""
|
|
233
|
+
return len(self._done)
|