refactor(orders): SQL приёма вынесен из DAG

- Зачем:
  - преобразования хранилища должны читаться рядом с целевым слоем, а DAG должен показывать оркестрацию.
- Что:
  - запросы забора и разбора заказов разложены по каталогам STG и ODS.
  - общий контракт провода подключён в обе ветви штатным шаблонизатором Airflow.
  - SQL смонтирован во все службы Airflow, а правило раскладки записано в архитектуре.
- Проверка:
  - make lint config-test smoke check-services.
  - airflow tasks render для pull_batch и parse_batch; airflow tasks test для parse_batch.
This commit is contained in:
2026-08-18 23:55:04 +03:00
parent bdcbe48b59
commit a5508bd9ec
8 changed files with 195 additions and 165 deletions
+61
View File
@@ -0,0 +1,61 @@
-- Форма провода одной строки слепка: общая часть обеих вставок ODS.
--
-- Строгий приём: проверяется форма провода и ничего сверх неё.
--
-- Ключей одиннадцать, и проверяются все: десять скалярных значений прямо
-- образуют типизированную строку заказа. Внутрь items приём не смотрит —
-- содержимое позиций, переходы статуса и равенства сумм остаются ниже границы
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
--
-- Файл включают обе вставки нарочно: годная ветвь берёт row_is_valid, брак —
-- буквальное NOT. Разойдись условия хоть на символ — строка либо задвоится,
-- либо исчезнет молча.
--
-- Отсюда два запрета на выражения предиката, и оба серьёзные. Первый: ни одно
-- не возвращает NULL — трёхзначная логика дала бы строку, которую не берёт ни
-- условие, ни его отрицание. Поэтому сравнения дают 0 или 1, а обнуляемый
-- разбор заканчивается IS NOT NULL. Второй: ни одно не бросает исключений на
-- произвольном raw — иначе одна грязная строка роняет весь переход, ради
-- отсутствия чего таблица брака и заведена.
WITH
-- Каноническое время провода: UTC, ровно три знака долей секунды.
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
JSONType(raw) = 'Object' AS is_object,
arraySort(JSONExtractKeys(raw)) = arraySort([
'order_id', 'user_id', 'status', 'created_at', 'updated_at',
'items_total', 'discount', 'delivery', 'total', 'items',
'snapshot_date'
]) AS keys_match,
JSONType(raw, 'order_id') = 'String'
AND JSONType(raw, 'status') = 'String'
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним
-- Int64 законный идентификатор за 2^63 уехал бы в брак.
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
AND JSONType(raw, 'items') = 'Array'
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы:
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md,
-- «Что проверено».
AND arrayAll(k ->
JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k),
'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$')
AND parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
['created_at', 'updated_at'])
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит
-- форму, разбор — вместимость: у строки из двадцати девяток форма
-- хороша, а в Decimal(18, 2) она не влезает.
AND arrayAll(k ->
JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$')
AND toDecimal64OrNull(JSONExtractString(raw, k), 2) IS NOT NULL,
['items_total', 'discount', 'delivery', 'total'])
AND JSONType(raw, 'snapshot_date') = 'String'
AND match(JSONExtractString(raw, 'snapshot_date'), '^\\d{4}-\\d{2}-\\d{2}$')
AND parseDateTimeInJodaSyntaxOrNull(
JSONExtractString(raw, 'snapshot_date'), 'yyyy-MM-dd', 'UTC') IS NOT NULL
AS fields_valid,
is_object AND keys_match AND fields_valid AS row_is_valid