diff --git a/tests/unit/test_dv2_supplier_reference.py b/tests/unit/test_dv2_supplier_reference.py index 14109716..ea70e38d 100644 --- a/tests/unit/test_dv2_supplier_reference.py +++ b/tests/unit/test_dv2_supplier_reference.py @@ -76,10 +76,14 @@ def test_ru_inn10_check_digit_known_vector(): def test_tnved_headings_are_real_format(): - assert len(TNVED_HEADINGS) >= 30 + # 10 catalog categories (generator-spec.md §3), one split across two + # headings (vacuum sealers vs dryers) -> 11 heading entries. + assert len(TNVED_HEADINGS) == 11 + allowed_headings = {"8516", "8509", "8423", "8422"} for h in TNVED_HEADINGS: assert len(h.heading) == 4 assert h.heading.isdigit() + assert h.heading in allowed_headings assert h.code10 == f"{h.heading}000000" assert len(h.code10) == 10 assert h.description.strip() diff --git a/tests/unit/test_generator_spec_invariants.py b/tests/unit/test_generator_spec_invariants.py new file mode 100644 index 00000000..c1ac7a4d --- /dev/null +++ b/tests/unit/test_generator_spec_invariants.py @@ -0,0 +1,226 @@ +"""Machine-checkable consistency invariants from ``docs/generator-spec.md`` +§12 — the definition of "цифры взаимно согласованы" for the B1 data rebuild. + +Invariants #1-#10 are pure arithmetic over :mod:`legend`'s constants (no +warehouse needed). #5/#7/#8 are also checked against the actual +:func:`build_reference` output. #11/#12 (faux-PII locale rules, loyalty +gating) are structural regression guards over the seed SQL text — a live +ClickHouse/Postgres re-verify is B4's job, not this file's. +""" + +from __future__ import annotations + +from pathlib import Path + +from warehouse.agentflow.dv2.reference import legend +from warehouse.agentflow.dv2.reference.generator import build_reference +from warehouse.agentflow.dv2.reference.gs1 import is_valid_gtin13 +from warehouse.agentflow.dv2.reference.tnved import TNVED_HEADINGS + +DV2_ROOT = Path(__file__).resolve().parents[2] / "warehouse" / "agentflow" / "dv2" + +_MARKETPLACE_CHANNELS = {"marketplace_fbs"} +_D2C_CHANNELS = {"d2c_site"} +_B2B_CHANNELS = {"b2b_wholesale", "b2b_re_export", "b2b_eaeu"} + + +def _annual_revenue_rub() -> float: + daily = sum(orders * check for _, _, orders, check in legend.MASTER_MATRIX) + return daily * 365 + + +def _orders_by_group() -> dict[str, int]: + totals = {"marketplace": 0, "d2c": 0, "b2b": 0} + for channel, _, orders, _ in legend.MASTER_MATRIX: + if channel in _MARKETPLACE_CHANNELS: + totals["marketplace"] += orders + elif channel in _D2C_CHANNELS: + totals["d2c"] += orders + else: + totals["b2b"] += orders + return totals + + +def _revenue_by_group() -> dict[str, float]: + totals = {"marketplace": 0.0, "d2c": 0.0, "b2b": 0.0} + for channel, _, orders, check in legend.MASTER_MATRIX: + revenue = orders * check + if channel in _MARKETPLACE_CHANNELS: + totals["marketplace"] += revenue + elif channel in _D2C_CHANNELS: + totals["d2c"] += revenue + else: + totals["b2b"] += revenue + return totals + + +# --- #1 annual revenue ------------------------------------------------------- + + +def test_invariant_1_annual_revenue_in_corridor(): + annual_b_rub = _annual_revenue_rub() / 1_000_000_000 + assert 3.5 <= annual_b_rub <= 5.0 + + +# --- #2 order-count mix ------------------------------------------------------ + + +def test_invariant_2_order_count_mix(): + totals = _orders_by_group() + grand_total = sum(totals.values()) + marketplace_pct = 100 * totals["marketplace"] / grand_total + b2b_pct = 100 * totals["b2b"] / grand_total + d2c_pct = 100 * totals["d2c"] / grand_total + assert 88 <= marketplace_pct <= 90 + assert 7 <= b2b_pct <= 9 + assert 2 <= d2c_pct <= 4 + + +# --- #3 revenue mix ----------------------------------------------------------- + + +def test_invariant_3_revenue_mix(): + totals = _revenue_by_group() + grand_total = sum(totals.values()) + b2b_pct = 100 * totals["b2b"] / grand_total + marketplace_pct = 100 * totals["marketplace"] / grand_total + assert 65 <= b2b_pct <= 72 + assert 27 <= marketplace_pct <= 33 + + +# --- #4 bimodal AOV ----------------------------------------------------------- + + +def test_invariant_4_bimodal_avg_checks_no_mass_in_gap(): + # §1's master matrix pins dxb (re-export, "export pallets", thinner + # margin per §5) at a 90k avg check — outside the general [30k, 80k] B2B + # band the same table implies for the domestic + EAEU wholesale channels. + # Read narrowly: the [30k, 80k] band covers RU + ala wholesale; dxb is a + # documented, table-explicit outlier, not a spec violation. + domestic_b2b_checks = [ + check + for channel, branch, _, check in legend.MASTER_MATRIX + if channel in _B2B_CHANNELS and branch != "dxb" + ] + marketplace_checks = [ + check for channel, _, _, check in legend.MASTER_MATRIX if channel in _MARKETPLACE_CHANNELS + ] + assert all(30_000 <= c <= 80_000 for c in domestic_b2b_checks) + assert all(1_500 <= c <= 3_000 for c in marketplace_checks) + # no channel's avg check falls in the 10k-25k dead zone (holds for all + # channels, including dxb) + assert all(not (10_000 < check < 25_000) for _, _, _, check in legend.MASTER_MATRIX) + + +# --- #5 pricing ladder --------------------------------------------------------- + + +def test_invariant_5_pricing_ladder_bands_are_disjoint_and_ordered(): + assert legend.FOB_PCT_RANGE[1] < legend.LANDED_PCT_RANGE[0] + assert legend.LANDED_PCT_RANGE[1] < legend.WHOLESALE_PCT_RANGE[0] + assert legend.WHOLESALE_PCT_RANGE[1] < legend.MARKETPLACE_NET_PCT + assert legend.MARKETPLACE_NET_PCT < legend.RRC_PCT + + +def test_invariant_5_pricing_ladder_holds_per_sku(): + tables = build_reference() + rrc_by_sku = {p.product_bk: p.rrc_price for p in tables.products} + for sourcing in tables.sourcing: + rrc = rrc_by_sku[sourcing.product_bk] + fob_pct = sourcing.purchase_price / rrc + assert legend.FOB_PCT_RANGE[0] <= fob_pct <= legend.FOB_PCT_RANGE[1] + + +# --- #6 seasonal curves average to 1.0 ----------------------------------------- + + +def test_invariant_6_seasonal_curves_average_to_one(): + assert len(legend.SEASONAL_RETAIL) == 12 + assert len(legend.SEASONAL_B2B) == 12 + assert abs(sum(legend.SEASONAL_RETAIL) / 12 - 1.0) < 1e-9 + assert abs(sum(legend.SEASONAL_B2B) / 12 - 1.0) < 1e-9 + + +# --- #7 GTIN validity ----------------------------------------------------------- + + +def test_invariant_7_every_gtin_valid_and_in_eaeu_range(): + tables = build_reference() + for product in tables.products: + assert is_valid_gtin13(product.gtin) + assert 460 <= int(product.gtin[:3]) <= 469 + + +# --- #8 tnved headings ---------------------------------------------------------- + + +def test_invariant_8_every_tnved_code_matches_a_spec_heading(): + allowed_headings = {"8516", "8509", "8423", "8422"} + assert {h.heading for h in TNVED_HEADINGS} <= allowed_headings + tables = build_reference() + for product in tables.products: + assert product.tnved_code.endswith("000000") + assert len(product.tnved_code) == 10 + assert product.tnved_code[:4] in allowed_headings + + +# --- #9 dealer ordering frequency -> B2B orders/day ----------------------------- + + +def test_invariant_9_dealer_frequency_yields_150_to_200_b2b_orders_per_day(): + assert sum(count for _, count, _ in legend.DEALER_FREQUENCY_TIERS) == legend.DEALER_CUSTOMERS + weekly_orders = sum(count * freq for _, count, freq in legend.DEALER_FREQUENCY_TIERS) + daily_b2b_orders = weekly_orders / 7 + assert 150 <= daily_b2b_orders <= 200 + + +# --- #10 branch revenue shares --------------------------------------------------- + + +def test_invariant_10_branch_revenue_shares(): + by_branch: dict[str, float] = {} + for _, branch, orders, check in legend.MASTER_MATRIX: + by_branch[branch] = by_branch.get(branch, 0.0) + orders * check + grand_total = sum(by_branch.values()) + shares = {branch: 100 * revenue / grand_total for branch, revenue in by_branch.items()} + assert abs(sum(shares.values()) - 100) < 1e-6 + assert 55 <= shares["msk"] <= 65 + + +# --- #11 faux-PII locale rules (structural, over seed SQL text) ----------------- + + +def _read_seed_sql(*names: str) -> str: + return "\n".join((DV2_ROOT / name).read_text(encoding="utf-8") for name in names) + + +def test_invariant_11_faux_pii_locale_rules_present_in_seed_sql(): + seed_sql = _read_seed_sql("satellite_seed.sql", "satellite_seed_all_branches.sql") + # RU jurisdictions (§8): city phone prefixes + @example.test emails. + assert "+7495" in seed_sql # msk + assert "+7812" in seed_sql # spb + assert "+7343" in seed_sql # ekb + assert "@example.test" in seed_sql + # AE jurisdiction (dxb): +971 phones, latin transliteration names, .ae emails. + assert "+971" in seed_sql + assert "@example.ae" in seed_sql + # KZ jurisdiction (ala): +7727 phones, .kz emails. + assert "+7727" in seed_sql + assert "@example.kz" in seed_sql + + +# --- #12 loyalty gating ----------------------------------------------------------- + + +def test_invariant_12_loyalty_points_cap_is_within_three_percent_of_min_quarterly_spend(): + min_quarterly_spend = legend._TAIL_ORDERS_PER_WEEK * legend._RU_B2B_AVG_CHECK_RUB * 13 + assert legend.LOYALTY_POINTS_MAX_RUB <= legend.LOYALTY_RETRO_BONUS_PCT * min_quarterly_spend + + +def test_invariant_12_loyalty_rows_only_for_eligible_branches_in_seed_sql(): + assert legend.LOYALTY_ELIGIBLE_BRANCHES == ("msk", "spb", "ekb") + seed_sql = _read_seed_sql("satellite_seed.sql", "satellite_seed_all_branches.sql") + for branch in legend.LOYALTY_ELIGIBLE_BRANCHES: + assert f"sat_customer_loyalty__bitrix__{branch}" in seed_sql + for branch in ("dxb", "ala"): + assert f"sat_customer_loyalty__bitrix__{branch}" not in seed_sql diff --git a/warehouse/agentflow/dv2/cold_offload_seed.sql b/warehouse/agentflow/dv2/cold_offload_seed.sql index 0728a08e..07f9c95a 100644 --- a/warehouse/agentflow/dv2/cold_offload_seed.sql +++ b/warehouse/agentflow/dv2/cold_offload_seed.sql @@ -3,8 +3,10 @@ -- deterministic hash of the customer number so the cold export has real -- rows to ship without touching jurisdictional PII. -- --- One row per msk customer (~800 rows). Re-runnable: hash_diff makes --- the satellite ReplacingMergeTree-friendly even though it's MergeTree. +-- One row per msk customer: retail [0,2000) + dealer msk [2000,2190) — +-- 2,190 rows, matching hub_customer's msk band (synthetic_seed.sql header). +-- Re-runnable: hash_diff makes the satellite ReplacingMergeTree-friendly +-- even though it's MergeTree. INSERT INTO rv.sat_customer_anon__1c__msk (customer_hk, load_ts, hash_diff, record_source, @@ -33,5 +35,4 @@ SELECT 'new' ) AS customer_segment, 0 AS is_deleted -FROM numbers(2000) -WHERE number % 100 < 40; -- msk slice = 40% of 2000 customers +FROM numbers(2190); -- msk slice: retail [0,2000) + dealer msk [2000,2190) diff --git a/warehouse/agentflow/dv2/postgres_oltp/seed.sql b/warehouse/agentflow/dv2/postgres_oltp/seed.sql index a96cd201..7db41a7b 100644 --- a/warehouse/agentflow/dv2/postgres_oltp/seed.sql +++ b/warehouse/agentflow/dv2/postgres_oltp/seed.sql @@ -1,4 +1,6 @@ --- Hot-tier OLTP seed for the DV2.0 demo. +-- Hot-tier OLTP seed for the DV2.0 demo (own-brand kitchen-appliance +-- importer legend — see synthetic_seed.sql for the full customer/order +-- numbering this small sample mirrors at hot-tier scale). -- Lives in Postgres 17, per-branch schema layout (ops_) so the -- CDC bridge described in docs/dv2-multi-branch/architecture.md can route -- straight by schema name. Each schema gets its own customers + orders table. @@ -56,7 +58,7 @@ SELECT (ARRAY['Anna','Boris','Dasha','Egor','Fedor','Galya','Ivan','Kira','Lena','Mark'])[(n % 10) + 1], (ARRAY['Ivanov','Petrov','Sidorov','Smirnov','Volkov','Orlov','Lebedev','Sokolov'])[(n % 8) + 1], 'oltp' || n::text || '@example.test', - '+7916' || lpad((n * 137 % 10000000)::text, 7, '0') + '+7495' || lpad((n * 137 % 10000000)::text, 7, '0') FROM generate_series(1, 50) AS n ON CONFLICT (customer_id) DO NOTHING; diff --git a/warehouse/agentflow/dv2/reference/README.md b/warehouse/agentflow/dv2/reference/README.md index 2480e0a4..2ff7ffe3 100644 --- a/warehouse/agentflow/dv2/reference/README.md +++ b/warehouse/agentflow/dv2/reference/README.md @@ -1,10 +1,13 @@ # DV2 Supplier / Product Reference -A reproducible grocery **reference** (suppliers, products, GS1 marking codes, -product→supplier sourcing) for the AgentFlow DV2 raw vault. It fills the -catalog / `tnved_code` / GS1-marking slots that the transactional X5 feed -leaves empty, and is the project's genuine **cloud** component: the dataset is -published to a Hugging Face Dataset — real object storage, not a checkbox. +A reproducible small-kitchen-appliance **reference** (suppliers, products, GS1 +marking codes, product→supplier sourcing) for the AgentFlow DV2 raw vault — +the own-brand importer legend in [`docs/domain.md`](../../../../docs/domain.md), +pinned to exact numbers in [`docs/generator-spec.md`](../../../../docs/generator-spec.md). +It fills the catalog / `tnved_code` / GS1-marking slots that the transactional +feeds leave empty, and is the project's genuine **cloud** component: the +dataset is published to a Hugging Face Dataset — real object storage, not a +checkbox. It is a *reference* (master/dimension) feed, distinct from the `1c` / `wms` / `wb` transactional sources. Provenance is explicit: every row carries @@ -17,21 +20,31 @@ Kept deliberately honest — the value is in real storage + real standards conformance, not in pretending the identities are real. **Genuine (verifiable, pinned by tests):** -- ТН ВЭД ЕАЭС headings — real 4-digit HS-aligned customs headings with - descriptions close to the official Russian wording (`tnved.py`). +- ТН ВЭД ЕАЭС headings — real 4-digit HS-aligned customs headings (8516/8509/ + 8423/8422) with descriptions close to the official Russian wording + (`tnved.py`). - GS1 **GTIN-13** and **GLN-13** check digits — published GS1 mod-10 algorithm (`gs1.py`). - **RU INN-10** control digit — real algorithm for RU legal-entity tax ids. -- EAEU GS1 prefix range **460–469**. +- EAEU GS1 prefix range **460–469** — correct for this reference even though + manufacturing is contracted to China: GTINs belong to the RU brand owner + registered with GS1 RUS, regardless of where the goods are made. - `gross_weight_g >= net_weight_g` packaging invariant. -- MD5 hash keys computed with the **same canonicalisation as the X5 loader**, - so reference hubs/links join byte-for-byte with vault data already loaded - from other sources (pinned in `tests/unit/test_dv2_supplier_reference.py`). +- Pricing-ladder ordering per SKU: FOB < landed < wholesale < marketplace-net + < RRC (generator-spec.md §5) — guaranteed by disjoint percentage bands. +- MD5 hash keys computed with the **same canonicalisation as the + transactional loader**, so reference hubs/links join byte-for-byte with + vault data already loaded from other sources (pinned in + `tests/unit/test_dv2_supplier_reference.py`). **Synthetic but labelled:** -- supplier legal names and brand names; +- supplier legal names — **no brand token** anywhere in product data + (own-brand importer decision, generator-spec.md §3); +- CN USCC-18 tax ids — structurally shaped per GB 32100-2015, but the check + character is a labelled placeholder, not a verified mod-31 check digit + (`make_cn_uscc18`); - the specific SKU ↔ GTIN ↔ supplier assignments; -- packaging dimensions and purchase prices; +- packaging dimensions, RRC and FOB purchase prices; - GPC brick codes (illustrative); - ТН ВЭД sub-position digits — the genuine heading is zero-padded to the 10-digit field (`000000`), i.e. heading granularity, **not** a diff --git a/warehouse/agentflow/dv2/reference/build.py b/warehouse/agentflow/dv2/reference/build.py index 1dddc861..36687baf 100644 --- a/warehouse/agentflow/dv2/reference/build.py +++ b/warehouse/agentflow/dv2/reference/build.py @@ -23,6 +23,7 @@ import pandas as pd from .generator import ReferenceTables, build_reference +from .legend import GENERATOR_SEED, TOTAL_PRODUCTS, TOTAL_SUPPLIERS from .vault_mapping import RECORD_SOURCE, map_reference @@ -60,15 +61,16 @@ def _manifest(tables: ReferenceTables, load_ts: datetime, vault: dict[str, pd.Da "RU INN-10 control digit", "EAEU GS1 prefix range 460-469", "gross_weight_g >= net_weight_g invariant", - "MD5 hash keys join-compatible with the X5 / 1C vault feeds", + "pricing ladder ordering (FOB < landed < wholesale < marketplace-net < RRC)", + "MD5 hash keys join-compatible with the transactional vault feeds", ], "synthetic_but_labelled": [ - "supplier legal names", - "brand names", + "supplier legal names (no brand token anywhere in the data)", "SKU <-> GTIN <-> supplier assignments", - "packaging dimensions and purchase prices", + "packaging dimensions, RRC and FOB purchase prices", "GPC brick codes (illustrative)", "ТН ВЭД sub-position digits (zero-padded; heading granularity)", + "CN USCC-18 check character (structurally shaped, not GB 32100-2015 verified)", ], } @@ -77,9 +79,9 @@ def _manifest(tables: ReferenceTables, load_ts: datetime, vault: dict[str, pd.Da @click.option( "--out-dir", default="reference/build", type=click.Path(file_okay=False, path_type=Path) ) -@click.option("--seed", default=20260626, type=int) -@click.option("--n-suppliers", default=40, type=int) -@click.option("--n-products", default=300, type=int) +@click.option("--seed", default=GENERATOR_SEED, type=int) +@click.option("--n-suppliers", default=TOTAL_SUPPLIERS, type=int) +@click.option("--n-products", default=TOTAL_PRODUCTS, type=int) @click.option("--load-ts", default=None, help="Fixed UTC load timestamp (ISO-8601), else now.") @click.option("--dry-run", is_flag=True, help="Build and summarize without writing files.") def main( diff --git a/warehouse/agentflow/dv2/reference/generator.py b/warehouse/agentflow/dv2/reference/generator.py index 31bf7187..c5edb117 100644 --- a/warehouse/agentflow/dv2/reference/generator.py +++ b/warehouse/agentflow/dv2/reference/generator.py @@ -1,15 +1,18 @@ """Deterministic generator for the AgentFlow supplier / product reference. -Given a seed, ``build_reference`` produces a coherent, reproducible grocery -reference (suppliers, products, GS1 marking codes, product->supplier -sourcing) for the X5 / EAEU context. +Given a seed, ``build_reference`` produces a coherent, reproducible small +kitchen-appliance reference (suppliers, products, GS1 marking codes, +product->supplier sourcing) for the own-brand importer legend +(``docs/domain.md``, ``docs/generator-spec.md``). What is genuine vs. synthetic (kept explicit, see README): * genuine: ТН ВЭД headings, GS1 GTIN-13 / GLN-13 check digits, RU INN-10 check digit, EAEU GS1 prefix range, gross >= net packaging invariant; -* synthetic but plausible & labelled: supplier legal names, brand names, - specific SKU<->GTIN<->supplier assignments, packaging dimensions, prices. +* synthetic but plausible & labelled: supplier legal names, the specific + SKU<->GTIN<->supplier assignments, packaging dimensions, prices, and the + CN USCC-18 check character (structurally shaped, **not** a verified + GB 32100-2015 check digit — see :func:`make_cn_uscc18`). The output is storage-neutral (dataclasses / DataFrames). Landing it into the DV2 raw vault is the job of :mod:`vault_mapping`; publishing it to cloud @@ -20,75 +23,91 @@ import random from dataclasses import dataclass, field -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal from .gs1 import EAEU_PREFIX_RANGE, gtin13_check_digit, make_gtin13 +from .legend import ( + BASE_CATEGORY_QUOTAS, + COUNTRY_WEIGHTS, + FOB_PCT_RANGE, + GENERATOR_SEED, + TOTAL_PRODUCTS, + TOTAL_SUPPLIERS, +) from .tnved import TNVED_HEADINGS, TnvedHeading -# Branch geography -> ISO country, matching the AgentFlow 5-branch model -# (msk/spb/ekb = RU, ala = KZ, dxb = AE) plus BY as an EAEU sourcing origin. -COUNTRY_WEIGHTS: tuple[tuple[str, int], ...] = (("RU", 70), ("KZ", 12), ("BY", 10), ("AE", 8)) - -SUPPLIER_LEGAL_FORMS: tuple[str, ...] = ("ООО", "АО", "ПАО", "ТД") -SUPPLIER_NAME_STEMS: tuple[str, ...] = ( - "Молочный Стандарт", - "Мясной Дом", - "Северная Пекарня", - "ЮгАгро", - "ПродИмпорт", - "Сибирская Нива", - "Балтийский Улов", - "ВолгаПродукт", - "Уральские Фермы", - "ГринФрут", - "ЧайКофеТрейд", - "Кондитер Плюс", - "МаслоПром", - "АкваИсток", - "БакалеяОпт", - "Фуд Альянс", - "Агрохолдинг Восток", - "ПремиумФрукт", - "Рыбный Причал", - "Хлебный Край", - "СладкоТорг", - "НатурПродукт", - "Эко Ферма", - "ГастрономЪ", - "ПродСоюз", - "ТоргСервис", - "Деликатес", - "ВкусМаркет", - "Регион Продукт", - "Снаб Логистик", +# --- supplier naming pools ---------------------------------------------------- + +CN_CITY_STEMS: tuple[str, ...] = ( + "Foshan", + "Ningbo", + "Shenzhen", + "Cixi", + "Yongkang", + "Zhongshan", + "Dongguan", + "Taizhou", + "Hangzhou", + "Guangzhou", + "Shunde", + "Ciqing", + "Jieyang", + "Chaozhou", + "Yuyao", +) +CN_SUFFIXES: tuple[str, ...] = ( + "Electric Appliance Co., Ltd.", + "Household Appliance Co., Ltd.", + "Kitchenware Manufacturing Co., Ltd.", + "Electronics Co., Ltd.", + "Housewares Co., Ltd.", + "Smart Home Appliance Co., Ltd.", +) +RU_SUPPLIER_STEMS: tuple[str, ...] = ( + "УпакТорг", + "БумПак Сервис", + "КабельКомплект", + "ПечатьЛайн", + "КомплектСнаб", + "ТараПром", + "ИнструкцияПринт", + "МаркПак", +) +AE_SUPPLIER_NAMES: tuple[str, ...] = ( + "Jebel Ali Trading FZE", + "Gulf Gate General Trading LLC", + "Al Falah Consolidators FZCO", + "Dubai Bridge Trading FZE", + "Emirates Cargo Hub General Trading LLC", + "Jafza Link Trading FZCO", ) -BRANDS: tuple[str, ...] = ( - "Любимый Край", - "Домик в Деревне", - "Каждый День", - "Красная Цена", - "Простоквашино", - "Чёрный Жемчуг", - "Зелёная Линия", - "Особый Рецепт", - "Первым Делом", - "Сытый Кот", - "Фермерское", - "Золотая Нива", - "Свежесть", - "Традиция", - "Эконом", +KZ_SUPPLIER_NAMES: tuple[str, ...] = ( + "Алатау Дистрибьюшн", + "ЕвразияСервис Логистик", + "Алматы Снаб Транзит", + "Достык Трейд Сервис", ) -PACK_TYPES: tuple[str, ...] = ("Пакет", "Коробка", "Бутылка", "Банка", "Лоток", "Туба", "Дой-пак") + +SUPPLIER_LEGAL_FORMS: tuple[str, ...] = ("ООО", "АО", "ПАО", "ТД") SUPPLIER_STATUSES: tuple[tuple[str, int], ...] = (("active", 88), ("inactive", 8), ("suspended", 4)) MARKING_STATUSES: tuple[tuple[str, int], ...] = ( ("issued", 82), ("in_circulation", 14), ("withdrawn", 4), ) +PACK_TYPES: tuple[str, ...] = ( + "Коробка", + "Коробка с ручкой", + "Групповая упаковка", + "Индивидуальная упаковка", +) _INN10_WEIGHTS = (2, 4, 10, 3, 5, 9, 4, 6, 8) +# GB 32100-2015 (统一社会信用代码) 31-char alphabet: digits + letters, excluding +# I/O/S/V/Z (visually ambiguous with 1/0/5/... in Chinese official use). +_USCC_ALPHABET = "0123456789ABCDEFGHJKLMNPQRTUWXY" + def ru_inn10_check_digit(first9: str) -> int: """Real control digit for a 10-digit RU INN (legal entity).""" @@ -103,6 +122,24 @@ def make_ru_inn10(rng: random.Random) -> str: return first9 + str(ru_inn10_check_digit(first9)) +def make_cn_uscc18(rng: random.Random) -> str: + """Mint an 18-char CN USCC (统一社会信用代码), structurally shaped. + + The first two positions (registration-department / organization-category + code) and the 6-digit administrative-division code follow the real + GB 32100-2015 layout. The 18th (check) character is a **labelled + placeholder**, not a verified GB 32100-2015 mod-31 check digit — cheaper + and, unverified, safer than shipping a check-digit algorithm we cannot + confirm against a known-good vector. See README "synthetic but labelled". + """ + reg_dept = "9" # enterprise + org_category = rng.choice("1239") + division = "".join(rng.choice("0123456789") for _ in range(6)) + body = "".join(rng.choice(_USCC_ALPHABET) for _ in range(9)) + check = rng.choice(_USCC_ALPHABET) + return reg_dept + org_category + division + body + check + + def make_gln13(rng: random.Random, prefix: int) -> str: """A GS1 GLN-13 (same mod-10 check as GTIN) for a supplier location.""" payload = f"{prefix:03d}{rng.randint(0, 10**9 - 1):09d}" @@ -115,9 +152,26 @@ def _weighted_choice(rng: random.Random, options: tuple[tuple[str, int], ...]) - return rng.choices(population, weights=weights, k=1)[0] +def _largest_remainder_allocation( + total: int, weights: tuple[tuple[str, int], ...] +) -> dict[str, int]: + """Allocate ``total`` items across ``weights`` (label, weight) pairs so the + counts sum to exactly ``total`` while tracking the weights proportionally + (largest-remainder / Hamilton apportionment). Deterministic, no RNG. + """ + weight_sum = sum(w for _, w in weights) + shares = {label: total * w / weight_sum for label, w in weights} + counts = {label: int(share) for label, share in shares.items()} + remainder = total - sum(counts.values()) + order = sorted(shares, key=lambda label: shares[label] - counts[label], reverse=True) + for label in order[:remainder]: + counts[label] += 1 + return counts + + @dataclass(frozen=True, slots=True) class SupplierRef: - supplier_bk: str # tax id (INN / BIN / UNP / TRN) — the hub business key + supplier_bk: str # tax id (INN / USCC / TRN / BIN) — the hub business key supplier_name: str tax_country_code: str supplier_status: str @@ -128,12 +182,13 @@ class SupplierRef: class ProductRef: product_bk: str # reference SKU — the hub business key product_name: str - brand: str + brand: str # empty string: no-brand-token decision (generator-spec.md §3) category: str tnved_code: str gpc_brick_code: str gtin: str # GS1 marking-code business key marking_status: str + rrc_price: Decimal # recommended retail price, ₽, x,x90-style (§3/§5 rung 5) gross_weight_g: int net_weight_g: int length_mm: int @@ -148,7 +203,7 @@ class SourcingRef: product_bk: str supplier_bk: str supplier_priority: int # 1 = primary - purchase_price: Decimal + purchase_price: Decimal # FOB price, ₽ (§5 rung 1: 24-30% of RRC) min_order_qty: int lead_time_days: int valid_from: str # ISO date @@ -164,27 +219,46 @@ class ReferenceTables: def _make_suppliers(rng: random.Random, n: int) -> list[SupplierRef]: - stems = list(SUPPLIER_NAME_STEMS) - rng.shuffle(stems) + country_counts = _largest_remainder_allocation(n, COUNTRY_WEIGHTS) + countries: list[str] = [] + for country, count in country_counts.items(): + countries.extend([country] * count) + rng.shuffle(countries) + + ru_stems = list(RU_SUPPLIER_STEMS) + rng.shuffle(ru_stems) + ae_names = list(AE_SUPPLIER_NAMES) + rng.shuffle(ae_names) + kz_names = list(KZ_SUPPLIER_NAMES) + rng.shuffle(kz_names) + suppliers: list[SupplierRef] = [] seen_bk: set[str] = set() - for i in range(n): - country = _weighted_choice(rng, COUNTRY_WEIGHTS) - # Country-appropriate tax id; only RU INN carries a real check digit. - if country == "RU": + ru_i = ae_i = kz_i = 0 + for i, country in enumerate(countries): + if country == "CN": + bk = make_cn_uscc18(rng) + city = CN_CITY_STEMS[i % len(CN_CITY_STEMS)] + suffix = rng.choice(CN_SUFFIXES) + district = CN_CITY_STEMS[(i * 7) % len(CN_CITY_STEMS)] + name = f"{city} {district} {suffix}" if district != city else f"{city} {suffix}" + elif country == "RU": bk = make_ru_inn10(rng) - elif country == "KZ": - bk = "".join(str(rng.randint(0, 9)) for _ in range(12)) # БИН - elif country == "BY": - bk = "".join(str(rng.randint(0, 9)) for _ in range(9)) # УНП - else: - bk = "1000" + "".join(str(rng.randint(0, 9)) for _ in range(11)) # AE TRN + stem = ru_stems[ru_i % len(ru_stems)] + ru_i += 1 + suffix = "" if ru_i <= len(ru_stems) else f" №{ru_i // len(ru_stems) + 1}" + name = f"{_weighted_choice_form(rng)} «{stem}{suffix}»" + elif country == "AE": + bk = "1000" + "".join(str(rng.randint(0, 9)) for _ in range(11)) # AE TRN, 15 digits + name = ae_names[ae_i % len(ae_names)] + ae_i += 1 + else: # KZ + bk = "".join(str(rng.randint(0, 9)) for _ in range(12)) # KZ BIN + name = f"{kz_names[kz_i % len(kz_names)]} ТОО" + kz_i += 1 if bk in seen_bk: continue seen_bk.add(bk) - stem = stems[i % len(stems)] - suffix = "" if i < len(stems) else f" №{i // len(stems) + 1}" - name = f"{_weighted_choice_form(rng)} «{stem}{suffix}»" prefix = rng.choice(list(EAEU_PREFIX_RANGE)) suppliers.append( SupplierRef( @@ -202,63 +276,190 @@ def _weighted_choice_form(rng: random.Random) -> str: return rng.choices(list(SUPPLIER_LEGAL_FORMS), weights=[60, 20, 8, 12], k=1)[0] +# Catalog quotas and pricing-ladder bands live in :mod:`legend` (single +# source of truth, also asserted against by the §12 invariant tests). Bands +# are disjoint by construction (FOB max 0.30 < landed min 0.32 < wholesale +# min 0.60 < mp-net 0.78 < RRC 1.00), so any sample within each band preserves +# the FOB < landed < wholesale < marketplace-net < RRC chain per SKU. + + +def _scale_category_quotas(n_products: int) -> dict[str, int]: + weights = tuple((category, count) for category, count, _, _ in BASE_CATEGORY_QUOTAS) + return _largest_remainder_allocation(n_products, weights) + + +def _pick_rrc(rng: random.Random, low: int, high: int) -> Decimal: + """Recommended retail price, ₽, snapped to the x,x90 ending (§3).""" + lo_hundreds, hi_hundreds = low // 100, high // 100 + candidates = [ + h * 100 + 90 for h in range(lo_hundreds, hi_hundreds + 1) if low <= h * 100 + 90 <= high + ] + return Decimal(rng.choice(candidates or [low])) + + +def _quantize_money(value: Decimal) -> Decimal: + return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +# category -> attribute-based RU naming templates (no brand token). Each +# template is (base_name, attr_pools) where attr_pools are joined as +# comma-separated clauses to avoid RU adjective-agreement artefacts. +_NAME_SPECS: dict[str, tuple[tuple[str, tuple[tuple[str, ...], ...]], ...]] = { + "Электрочайники": ( + ( + "Чайник электрический", + ( + ("1 л", "1.2 л", "1.5 л", "1.7 л", "2 л"), + ("1800 Вт", "2000 Вт", "2200 Вт", "2400 Вт"), + ), + ), + ), + "Аэрогрили и грили": ( + ("Аэрогриль", (("3 л", "4 л", "5 л", "6 л"), ("1200 Вт", "1500 Вт", "1800 Вт"))), + ( + "Гриль электрический", + (("открытого типа", "закрытого типа"), ("1500 Вт", "1800 Вт", "2000 Вт")), + ), + ), + "Блендеры": ( + ("Блендер погружной", (("600 Вт", "700 Вт", "800 Вт", "1000 Вт"),)), + ("Блендер стационарный", (("1.5 л", "2 л"), ("500 Вт", "600 Вт", "700 Вт"))), + ), + "Миксеры": ( + ("Миксер ручной", (("300 Вт", "400 Вт", "500 Вт"),)), + ("Миксер планетарный", (("4 л", "5 л"), ("1000 Вт", "1200 Вт"))), + ), + "Кофеварки и кофемолки": ( + ("Кофеварка капельная", (("0.6 л", "1 л", "1.2 л"),)), + ("Кофеварка рожковая", (("15 бар", "19 бар"),)), + ("Кофемолка электрическая", (("150 Вт", "200 Вт"),)), + ), + "Мультипекари, вафельницы, сэндвичницы": ( + ("Мультипекарь", (("700 Вт", "800 Вт", "1000 Вт"),)), + ("Вафельница электрическая", (("800 Вт", "1000 Вт"),)), + ("Сэндвичница электрическая", (("700 Вт", "900 Вт"),)), + ), + "Измельчители": ( + ( + "Измельчитель электрический", + (("0.5 л", "0.8 л", "1 л", "1.5 л"), ("200 Вт", "300 Вт", "400 Вт")), + ), + ), + "Соковыжималки": ( + ("Соковыжималка шнековая", (("150 Вт", "200 Вт"),)), + ("Соковыжималка центробежная", (("400 Вт", "600 Вт", "800 Вт"),)), + ), + "Кухонные весы": (("Весы кухонные электронные", (("до 3 кг", "до 5 кг", "до 10 кг"),)),), + "Вакууматоры и сушилки": ( + ("Вакууматор бытовой", (("100 Вт", "120 Вт", "135 Вт"),)), + ( + "Сушилка для продуктов электрическая", + (("5 лотков", "6 лотков", "8 лотков"), ("250 Вт", "350 Вт", "500 Вт")), + ), + ), +} + + +_VACUUM_DRY_CATEGORY = "Вакууматоры и сушилки" + + +def _tnved_for_category_slot(category: str, index_in_category: int) -> TnvedHeading: + """Pick the ТН ВЭД heading (and, for the split category, the matching + name template index) for the ``index_in_category``-th SKU of + ``category``. Categories with a single heading always return it; + "Вакууматоры и сушилки" splits 5:3 vacuum-sealer (8422) : dryer (8516) + per 8-slot block, so both sub-types are represented across the quota. + """ + headings = [h for h in TNVED_HEADINGS if h.category == category] + if len(headings) == 1: + return headings[0] + return headings[0] if index_in_category % 8 < 5 else headings[1] + + +def _make_product_name(rng: random.Random, category: str, index_in_category: int) -> str: + templates = _NAME_SPECS[category] + if category == _VACUUM_DRY_CATEGORY: + # template[0] = vacuum sealer (8422), template[1] = dryer (8516) — + # same 5:3 split as _tnved_for_category_slot so name and heading agree. + template = templates[0] if index_in_category % 8 < 5 else templates[1] + else: + template = rng.choice(templates) + base, attr_pools = template + attrs = ", ".join(rng.choice(pool) for pool in attr_pools) + return f"{base}, {attrs}" if attrs else base + + def _make_products(rng: random.Random, n: int) -> list[ProductRef]: + quotas = _scale_category_quotas(n) + band_by_category = {cat: (low, high) for cat, _, low, high in BASE_CATEGORY_QUOTAS} products: list[ProductRef] = [] item_ref = rng.randint(10_000, 50_000) - for i in range(n): - heading: TnvedHeading = rng.choice(TNVED_HEADINGS) - brand = rng.choice(BRANDS) - # Clean SKU-style name ", , г" — avoids RU - # adjective-agreement artefacts while staying catalog-realistic. - commodity = heading.description.split(",")[0].split(" и ")[0] - net = rng.choice((150, 180, 200, 250, 330, 400, 450, 500, 750, 900, 1000)) - gross = net + rng.choice((8, 12, 18, 25, 35, 50)) - prefix = rng.choice(list(EAEU_PREFIX_RANGE)) - item_ref = (item_ref + rng.randint(1, 37)) % 10**9 - gtin = make_gtin13(prefix, item_ref) - sku = f"RC{i + 1:06d}" - products.append( - ProductRef( - product_bk=sku, - product_name=f"{commodity}, {brand}, {net} г", - brand=brand, - category=heading.category, - tnved_code=heading.code10, - gpc_brick_code=f"100{rng.randint(0, 99999):05d}", # illustrative GS1 GPC brick - gtin=gtin, - marking_status=_weighted_choice(rng, MARKING_STATUSES), - gross_weight_g=gross, - net_weight_g=net, - length_mm=rng.choice((60, 80, 100, 120, 160, 200)), - width_mm=rng.choice((40, 50, 60, 80, 100)), - height_mm=rng.choice((80, 120, 160, 200, 240, 300)), - units_per_pack=rng.choice((1, 1, 1, 6, 8, 12)), - pack_type=rng.choice(PACK_TYPES), + i = 0 + for category, _, _, _ in BASE_CATEGORY_QUOTAS: + quota = quotas[category] + low, high = band_by_category[category] + for slot in range(quota): + heading = _tnved_for_category_slot(category, slot) + rrc = _pick_rrc(rng, low, high) + net = rng.choice((350, 500, 700, 900, 1200, 1800, 2500, 3500)) + gross = net + rng.choice((80, 120, 180, 250, 350)) + prefix = rng.choice(list(EAEU_PREFIX_RANGE)) + item_ref = (item_ref + rng.randint(1, 37)) % 10**9 + gtin = make_gtin13(prefix, item_ref) + sku = f"RC{i + 1:06d}" + products.append( + ProductRef( + product_bk=sku, + product_name=_make_product_name(rng, category, slot), + brand="", + category=category, + tnved_code=heading.code10, + gpc_brick_code=f"100{rng.randint(0, 99999):05d}", # illustrative GS1 GPC brick + gtin=gtin, + marking_status=_weighted_choice(rng, MARKING_STATUSES), + rrc_price=rrc, + gross_weight_g=gross, + net_weight_g=net, + length_mm=rng.choice((150, 200, 250, 300, 350, 420)), + width_mm=rng.choice((120, 150, 200, 250, 300)), + height_mm=rng.choice((150, 200, 250, 320, 400)), + units_per_pack=rng.choices( + (1, 1, 1, 1, 4, 6), weights=(70, 70, 70, 70, 8, 4), k=1 + )[0], + pack_type=rng.choice(PACK_TYPES), + ) ) - ) + i += 1 return products def _make_sourcing( rng: random.Random, products: list[ProductRef], suppliers: list[SupplierRef] ) -> list[SourcingRef]: - active = [s for s in suppliers if s.supplier_status == "active"] or suppliers + cn_active = ( + [s for s in suppliers if s.tax_country_code == "CN" and s.supplier_status == "active"] + or [s for s in suppliers if s.tax_country_code == "CN"] + or suppliers + ) + quarters = ("2026-01-01", "2026-04-01", "2026-07-01", "2026-10-01") sourcing: list[SourcingRef] = [] for product in products: - n_suppliers = rng.choices((1, 2, 3), weights=(55, 33, 12), k=1)[0] - chosen = rng.sample(active, k=min(n_suppliers, len(active))) + n_suppliers = rng.choices((1, 2), weights=(60, 40), k=1)[0] + chosen = rng.sample(cn_active, k=min(n_suppliers, len(cn_active))) + fob_pct = rng.uniform(*FOB_PCT_RANGE) for priority, supplier in enumerate(chosen, start=1): - base_price = Decimal(rng.randint(35, 1200)) - cents = Decimal(rng.choice(("0.00", "0.50", "0.90", "0.99"))) + purchase_price = _quantize_money(product.rrc_price * Decimal(str(round(fob_pct, 4)))) + is_air = rng.random() < 0.10 + lead_time = rng.randint(12, 18) if is_air else rng.randint(40, 60) sourcing.append( SourcingRef( product_bk=product.product_bk, supplier_bk=supplier.supplier_bk, supplier_priority=priority, - purchase_price=base_price + cents, - min_order_qty=rng.choice((1, 6, 12, 24, 48, 100)), - lead_time_days=rng.choice((1, 2, 3, 5, 7, 10, 14)), - valid_from="2026-01-01", + purchase_price=purchase_price, + min_order_qty=rng.choice((300, 400, 500, 600, 800, 1000)), + lead_time_days=lead_time, + valid_from=rng.choice(quarters), valid_to=None, ) ) @@ -266,7 +467,10 @@ def _make_sourcing( def build_reference( - *, n_suppliers: int = 40, n_products: int = 300, seed: int = 20260626 + *, + n_suppliers: int = TOTAL_SUPPLIERS, + n_products: int = TOTAL_PRODUCTS, + seed: int = GENERATOR_SEED, ) -> ReferenceTables: """Build a deterministic supplier/product reference for the given seed.""" if n_suppliers < 1 or n_products < 1: diff --git a/warehouse/agentflow/dv2/reference/gs1.py b/warehouse/agentflow/dv2/reference/gs1.py index 89aab08d..bc2231c9 100644 --- a/warehouse/agentflow/dv2/reference/gs1.py +++ b/warehouse/agentflow/dv2/reference/gs1.py @@ -7,8 +7,10 @@ The data identities are synthetic, but the encoding is genuine: * GS1 prefixes ``460``-``469`` are the real EAEU (Russia / EAEU member - states) country range, so the reference is coherent with the rest of the - AgentFlow X5-grocery context. + states) country range. That is correct for this reference even though + manufacturing is contracted to China: GTINs belong to the RU brand owner + registered with GS1 RUS, regardless of where the goods are made — the + own-brand kitchen-appliance importer legend (``docs/domain.md``). * The 13th digit is computed with the published GS1 mod-10 weighting (odd positions weight 1, even positions weight 3, counting from the left over the 12 data digits). diff --git a/warehouse/agentflow/dv2/reference/legend.py b/warehouse/agentflow/dv2/reference/legend.py new file mode 100644 index 00000000..5678d281 --- /dev/null +++ b/warehouse/agentflow/dv2/reference/legend.py @@ -0,0 +1,152 @@ +"""Business-legend constants: the numeric single source of truth pinned in +``docs/generator-spec.md``. + +This module holds no logic — only the master matrix, seasonal curves, +customer-population and pricing-ladder numbers a human can read straight off +generator-spec.md's tables. Two consumers: + +1. :mod:`generator` imports the catalog quotas and pricing-ladder bands, so + the reference-catalog code and this module can't drift apart. +2. ``tests/unit/test_generator_spec_invariants.py`` asserts generator-spec.md + §12's consistency invariants directly against these constants — the + "цифры взаимно согласованы" contract made machine-checkable without a + live warehouse. + +The DV2 seed SQL (``synthetic_seed.sql``, ``satellite_seed*.sql``, +``postgres_oltp/seed.sql``) is hand-authored (ClickHouse/Postgres SQL can't +import this module) but its literal constants are derived from these same +numbers — each seed file's header comment cites the generator-spec.md section +it mirrors. +""" + +from __future__ import annotations + +# --- §1 master matrix — baseline day (seasonal multiplier 1.0) -------------- + +# (channel, branch, orders_per_day, avg_check_rub) +MASTER_MATRIX: tuple[tuple[str, str, int, int], ...] = ( + ("b2b_wholesale", "msk", 70, 52_000), + ("b2b_wholesale", "spb", 35, 52_000), + ("b2b_wholesale", "ekb", 25, 52_000), + ("b2b_re_export", "dxb", 15, 90_000), + ("b2b_eaeu", "ala", 15, 45_000), + ("marketplace_fbs", "msk", 1_750, 2_150), + ("d2c_site", "msk", 55, 3_300), +) + +# --- §4 seasonal calendar — 12 monthly multipliers, each curve averages 1.0 - + +SEASONAL_RETAIL: tuple[float, ...] = ( + 0.70, + 1.10, + 1.20, + 0.85, + 0.80, + 0.75, + 0.80, + 0.90, + 0.95, + 1.05, + 1.45, + 1.45, +) +SEASONAL_B2B: tuple[float, ...] = ( + 0.60, + 1.15, + 0.95, + 0.85, + 0.80, + 0.85, + 0.95, + 1.05, + 1.20, + 1.40, + 1.30, + 0.90, +) + +# --- §5 pricing ladder — share of RRC, disjoint bands by construction ------- + +FOB_PCT_RANGE: tuple[float, float] = (0.24, 0.30) +LANDED_PCT_RANGE: tuple[float, float] = (0.32, 0.40) +WHOLESALE_PCT_RANGE: tuple[float, float] = (0.60, 0.65) +MARKETPLACE_NET_PCT: float = 0.78 +RRC_PCT: float = 1.00 + +# --- §3 SKU catalog — 10 categories, 160 SKUs baseline ---------------------- + +# (category, count-at-160-baseline, rrc_low, rrc_high) +BASE_CATEGORY_QUOTAS: tuple[tuple[str, int, int, int], ...] = ( + ("Электрочайники", 22, 1490, 3990), + ("Аэрогрили и грили", 20, 3490, 7990), + ("Блендеры", 20, 1690, 4490), + ("Миксеры", 14, 1990, 7990), + ("Кофеварки и кофемолки", 18, 1990, 6990), + ("Мультипекари, вафельницы, сэндвичницы", 16, 1790, 3490), + ("Измельчители", 12, 1290, 2490), + ("Соковыжималки", 10, 2490, 5990), + ("Кухонные весы", 12, 790, 1490), + ("Вакууматоры и сушилки", 16, 2290, 5490), +) + +# --- §6 supplier country mix ------------------------------------------------- + +COUNTRY_WEIGHTS: tuple[tuple[str, int], ...] = (("CN", 72), ("RU", 16), ("AE", 8), ("KZ", 4)) +TOTAL_SUPPLIERS: int = 30 # 22 CN + 5 RU + 2 AE + 1 KZ + +# --- §7 customer populations ------------------------------------------------- + +RETAIL_CUSTOMERS: int = 2_000 # all msk jurisdiction +DEALER_ACCOUNTS_BY_BRANCH: dict[str, int] = { + "msk": 190, + "spb": 100, + "ekb": 70, + "dxb": 60, + "ala": 80, +} +DEALER_CUSTOMERS: int = sum(DEALER_ACCOUNTS_BY_BRANCH.values()) # 500 +TOTAL_CUSTOMERS: int = RETAIL_CUSTOMERS + DEALER_CUSTOMERS # 2,500 + +# Ordering-frequency tiers (§7): (tier, account_count, orders_per_week). +# core(200) + mid(200) + tail(100) = 500 = DEALER_CUSTOMERS. +DEALER_FREQUENCY_TIERS: tuple[tuple[str, int, float], ...] = ( + ("core", 200, 4.0), + ("mid", 200, 1.5), + ("tail", 100, 0.5), +) + +# Branches eligible for the dealer retro-bonus loyalty program (§8/§12 #12) — +# dxb/ala dealers are on contract terms, not the bonus program. +LOYALTY_ELIGIBLE_BRANCHES: tuple[str, ...] = ("msk", "spb", "ekb") +LOYALTY_RETRO_BONUS_PCT: float = 0.03 # 3% of trailing-quarter purchases + +# Safe upper bound for seeded loyalty_points: 3% of the smallest plausible +# trailing-quarter spend among loyalty-eligible (msk/spb/ekb) dealers — a +# tail-tier dealer (0.5 orders/week) at the RU B2B avg check. Any seed formula +# that caps loyalty_points at this constant satisfies §12 invariant #12 for +# every eligible dealer, not just the average one. +_TAIL_ORDERS_PER_WEEK = DEALER_FREQUENCY_TIERS[2][2] +_RU_B2B_AVG_CHECK_RUB = 52_000 # MASTER_MATRIX b2b_wholesale avg_check_rub +_MIN_QUARTERLY_SPEND_RUB = _TAIL_ORDERS_PER_WEEK * _RU_B2B_AVG_CHECK_RUB * 13 +LOYALTY_POINTS_MAX_RUB: int = 9_000 # < LOYALTY_RETRO_BONUS_PCT * _MIN_QUARTERLY_SPEND_RUB + +# --- §11 DV2 seed volumes — hub_order split ---------------------------------- + +TOTAL_PRODUCTS: int = 160 +ORDERS_MARKETPLACE: int = 8_900 +ORDERS_SITE: int = 280 +ORDERS_B2B_BY_BRANCH: dict[str, int] = { + "msk": 360, + "spb": 180, + "ekb": 130, + "dxb": 75, + "ala": 75, +} +TOTAL_ORDERS: int = ORDERS_MARKETPLACE + ORDERS_SITE + sum(ORDERS_B2B_BY_BRANCH.values()) # 10,000 + +# --- §10 currencies and determinism ------------------------------------------ + +FX_AED_RUB: float = 24.50 +FX_KZT_RUB: float = 0.175 +FX_CNY_RUB: float = 12.40 +GENERATOR_SEED: int = 20260626 diff --git a/warehouse/agentflow/dv2/reference/tnved.py b/warehouse/agentflow/dv2/reference/tnved.py index d485a44b..4fa44f9c 100644 --- a/warehouse/agentflow/dv2/reference/tnved.py +++ b/warehouse/agentflow/dv2/reference/tnved.py @@ -1,9 +1,11 @@ -"""ТН ВЭД ЕАЭС (EAEU customs nomenclature) grocery reference subset. +"""ТН ВЭД ЕАЭС (EAEU customs nomenclature) small-kitchen-appliance reference +subset. The codes here are **real** Harmonized-System / ТН ВЭД ЕАЭС *headings* (the first four digits, which are identical across the international HS and the -EAEU nomenclature) for common grocery commodities, paired with descriptions -close to the official Russian wording. +EAEU nomenclature) for the small-appliance headings an own-brand kitchen +importer actually classifies against, paired with descriptions close to the +official Russian wording. Honesty note (kept deliberately): a full ТН ВЭД ЕАЭС code is 10 digits; the last 6 select a specific commodity sub-position. We carry the genuine 4-digit @@ -12,8 +14,11 @@ That keeps the customs classification correct at heading level without inventing digits we cannot stand behind. -Each entry is tagged with the retail category it belongs to so product -generation stays coherent with the X5 grocery context. +Each entry is tagged with the catalog category (domain.md §1 / generator-spec +§3) it belongs to, so product generation stays coherent with the own-brand +kitchen-appliance importer legend. Categories stay RU-flavored — DV2/warehouse +content mirrors what 1С/Bitrix24 emit (generator-spec.md §3, RU vs EN split); +the EN-facing catalog is a serving-layer concern (repin step). """ from __future__ import annotations @@ -25,7 +30,7 @@ class TnvedHeading: heading: str # real 4-digit HS / ТН ВЭД heading description: str # description close to official RU wording - category: str # retail aisle this heading maps to + category: str # catalog category this heading maps to (RU, 1С-flavored) @property def code10(self) -> str: @@ -33,55 +38,74 @@ def code10(self) -> str: return f"{self.heading}000000" -# Curated grocery subset. Headings are genuine HS/ТН ВЭД headings. +# The 10 catalog categories (generator-spec.md §3), each pinned to its real +# HS/ТН ВЭД heading. "Вакууматоры и сушилки" genuinely splits across two +# headings (vacuum sealers are packing machinery; dryers are electrothermic +# appliances), so it carries two entries — the honest reading, not a +# simplification. TNVED_HEADINGS: tuple[TnvedHeading, ...] = ( - TnvedHeading("0201", "Мясо крупного рогатого скота, свежее или охлаждённое", "Мясо и птица"), - TnvedHeading("0203", "Свинина свежая, охлаждённая или замороженная", "Мясо и птица"), - TnvedHeading("0207", "Мясо и пищевые субпродукты домашней птицы", "Мясо и птица"), - TnvedHeading("0302", "Рыба свежая или охлаждённая", "Рыба и морепродукты"), - TnvedHeading("0303", "Рыба мороженая", "Рыба и морепродукты"), TnvedHeading( - "0401", "Молоко и сливки, несгущённые, без добавления сахара", "Молочные продукты" + "8516", + "Приборы электронагревательные бытового назначения; электрочайники " + "и аналогичные приборы для нагрева воды", + "Электрочайники", ), TnvedHeading( - "0403", "Йогурт, кефир и прочие ферментированные молочные продукты", "Молочные продукты" + "8516", + "Приборы электронагревательные бытового назначения; грили и аэрогрили электрические", + "Аэрогрили и грили", ), - TnvedHeading("0405", "Сливочное масло и прочие жиры из молока", "Молочные продукты"), - TnvedHeading("0406", "Сыры и творог", "Молочные продукты"), - TnvedHeading("0407", "Яйца птиц в скорлупе", "Молочные продукты"), - TnvedHeading("0701", "Картофель свежий или охлаждённый", "Овощи и фрукты"), - TnvedHeading("0702", "Томаты свежие или охлаждённые", "Овощи и фрукты"), - TnvedHeading("0703", "Лук репчатый, чеснок, лук-порей", "Овощи и фрукты"), - TnvedHeading("0805", "Цитрусовые плоды, свежие или сушёные", "Овощи и фрукты"), - TnvedHeading("0808", "Яблоки, груши и айва свежие", "Овощи и фрукты"), - TnvedHeading("0901", "Кофе, жареный или нежареный", "Чай и кофе"), - TnvedHeading("0902", "Чай ароматизированный или неароматизированный", "Чай и кофе"), - TnvedHeading("1001", "Пшеница и меслин", "Бакалея"), - TnvedHeading("1006", "Рис", "Бакалея"), - TnvedHeading("1101", "Мука пшеничная или пшенично-ржаная", "Бакалея"), - TnvedHeading("1509", "Масло оливковое и его фракции", "Масло и жиры"), - TnvedHeading("1512", "Масло подсолнечное, сафлоровое или хлопковое", "Масло и жиры"), - TnvedHeading("1601", "Колбасы и аналогичные продукты из мяса", "Мясо и птица"), - TnvedHeading("1602", "Готовые или консервированные продукты из мяса", "Мясо и птица"), - TnvedHeading("1701", "Сахар тростниковый или свекловичный", "Бакалея"), TnvedHeading( - "1704", "Кондитерские изделия из сахара без содержания какао", "Кондитерские изделия" + "8509", + "Машины электромеханические бытовые с вмонтированным электродвигателем; блендеры", + "Блендеры", ), TnvedHeading( - "1806", "Шоколад и прочие готовые продукты с содержанием какао", "Кондитерские изделия" + "8509", + "Машины электромеханические бытовые с вмонтированным электродвигателем; " + "миксеры, в т.ч. планетарные", + "Миксеры", + ), + TnvedHeading( + "8516", + "Приборы электронагревательные бытового назначения; кофеварки и кофемолки электрические", + "Кофеварки и кофемолки", + ), + TnvedHeading( + "8516", + "Приборы электронагревательные бытового назначения; мультипекари, " + "вафельницы и сэндвичницы электрические", + "Мультипекари, вафельницы, сэндвичницы", + ), + TnvedHeading( + "8509", + "Машины электромеханические бытовые с вмонтированным электродвигателем; " + "измельчители (чопперы)", + "Измельчители", + ), + TnvedHeading( + "8509", + "Машины электромеханические бытовые с вмонтированным электродвигателем; соковыжималки", + "Соковыжималки", + ), + TnvedHeading( + "8423", + "Оборудование для взвешивания бытового назначения; весы кухонные", + "Кухонные весы", + ), + TnvedHeading( + "8422", + "Машины для укупорки, герметизации тары; вакууматоры бытовые", + "Вакууматоры и сушилки", + ), + TnvedHeading( + "8516", + "Приборы электронагревательные бытового назначения; сушилки для продуктов электрические", + "Вакууматоры и сушилки", ), - TnvedHeading("1902", "Макаронные изделия", "Бакалея"), - TnvedHeading("1905", "Хлеб, мучные кондитерские изделия, печенье", "Хлеб и выпечка"), - TnvedHeading("2002", "Томаты, приготовленные или консервированные", "Бакалея"), - TnvedHeading("2009", "Соки фруктовые и овощные несброженные", "Напитки"), - TnvedHeading("2101", "Экстракты, эссенции и концентраты кофе и чая", "Чай и кофе"), - TnvedHeading("2103", "Соусы, приправы и смешанные приправы", "Бакалея"), - TnvedHeading("2106", "Пищевые продукты, в другом месте не поименованные", "Бакалея"), - TnvedHeading("2201", "Воды минеральные и газированные без сахара", "Напитки"), - TnvedHeading("2202", "Воды с добавлением сахара или ароматизаторов, прочие напитки", "Напитки"), ) -# Quick lookup: retail category -> headings (preserves declaration order). +# Quick lookup: catalog category -> headings (preserves declaration order). HEADINGS_BY_CATEGORY: dict[str, list[TnvedHeading]] = {} for _h in TNVED_HEADINGS: HEADINGS_BY_CATEGORY.setdefault(_h.category, []).append(_h) diff --git a/warehouse/agentflow/dv2/satellite_seed.sql b/warehouse/agentflow/dv2/satellite_seed.sql index 9e9c3a82..d5f09271 100644 --- a/warehouse/agentflow/dv2/satellite_seed.sql +++ b/warehouse/agentflow/dv2/satellite_seed.sql @@ -1,11 +1,18 @@ --- Satellite seed for the DV2.0 multi-branch demo. +-- Satellite seed for the DV2.0 multi-branch demo (own-brand kitchen-appliance +-- importer legend — see synthetic_seed.sql header for the customer/order +-- numbering this file re-slices against). -- Populates the satellites the synthetic_seed.sql skipped, so business_vault -- views return non-NULL rows for PII / loyalty / order header / order pricing. +-- This file: msk (retail + dealer) PII/loyalty/orders, dxb dealer PII. +-- satellite_seed_all_branches.sql: spb/ekb/ala PII+loyalty, all branches' +-- anon sats and remaining order header/pricing. -- --- All faux PII is deterministically derived from the customer number so the --- seed is repeatable and reproduces the same hash_diff on re-runs. +-- All faux PII is deterministically derived from the customer/order number +-- so the seed is repeatable and reproduces the same hash_diff on re-runs. --- ============ CUSTOMER PII (1C, msk slice) ============ +-- ============ CUSTOMER PII (1C, msk: retail [0,2000) + dealer [2000,2190)) == +-- generator-spec.md §8: dealer birth_date is dense (campaigns query it); +-- retail stays sparse (~40% filled). Phone prefix +7495 (msk landline code). INSERT INTO rv.sat_customer_personal__1c__msk (customer_hk, load_ts, hash_diff, record_source, first_name, last_name, email, phone, birth_date, pii_flag, is_deleted) @@ -19,14 +26,16 @@ SELECT arrayElement(['Ivanov','Petrov','Sidorov','Smirnov','Volkov','Orlov','Lebedev','Sokolov'], (number % 8) + 1) AS last_name, concat('cust', toString(number), '@example.test') AS email, - concat('+7916', lpad(toString(number % 10000000), 7, '0')) AS phone, - toDate('1960-01-01') + (number % (365 * 50)) AS birth_date, + concat('+7495', lpad(toString(number % 10000000), 7, '0')) AS phone, + if(number >= 2000 OR number % 5 < 2, -- dealer: 100%; retail: 40% + toDate('1960-01-01') + (number % (365 * 50)), NULL) AS birth_date, true AS pii_flag, 0 AS is_deleted -FROM numbers(2000) -WHERE number % 100 < 40; -- msk slice +FROM numbers(2190); -- retail [0,2000) + dealer msk [2000,2190) --- ============ CUSTOMER PII (1C, dxb slice) ============ +-- ============ CUSTOMER PII (1C, dealer dxb [2360,2420)) ============ +-- AE-appropriate names/phones (+971, latin transliteration) — dealer +-- contacts there are Gulf trading companies' buyers (§8). Dense birth_date. INSERT INTO rv.sat_customer_personal__1c__dxb (customer_hk, load_ts, hash_diff, record_source, first_name, last_name, email, phone, birth_date, pii_flag, is_deleted) @@ -44,13 +53,18 @@ SELECT toDate('1965-01-01') + (number % (365 * 45)) AS birth_date, true AS pii_flag, 0 AS is_deleted -FROM numbers(2000) -WHERE number % 100 >= 80 AND number % 100 < 90; -- dxb slice +FROM numbers(2420) +WHERE number >= 2360; -- dealer dxb [2360,2420) --- ============ CUSTOMER LOYALTY (Bitrix, msk slice) ============ --- Bitrix is the loyalty source of truth. ~80% of msk customers have a row, --- the rest are "no Bitrix profile yet" — they remain visible in --- bv_customer_mdm__msk via the LEFT JOIN, with loyalty_source = NULL. +-- ============ CUSTOMER LOYALTY (Bitrix, dealer msk [2000,2190)) ============ +-- Bitrix is the retro-bonus source of truth. Dealer-only (loyalty is +-- meaningless for retail here — domain.md §5.2/§8). ~80% coverage: the rest +-- are "no Bitrix profile yet", visible via the LEFT JOIN with loyalty_source +-- = NULL. loyalty_segment now reads core/mid/tail (the ordering-frequency +-- tiers, generator-spec.md §7/§8), not the old vip/gold/silver vocabulary. +-- loyalty_points capped at legend.LOYALTY_POINTS_MAX_RUB (9,000 ₽) — proven +-- in test_generator_spec_invariants.py to be <= 3% of the smallest plausible +-- trailing-quarter dealer spend (§12 invariant #12). INSERT INTO rv.sat_customer_loyalty__bitrix__msk (customer_hk, load_ts, hash_diff, record_source, loyalty_segment, loyalty_points, last_visit_at, is_deleted) @@ -59,59 +73,79 @@ SELECT now64(3) AS load_ts, MD5(concat(toString(number), '|loy|v1')) AS hash_diff, 'bitrix__msk' AS record_source, - arrayElement(['vip','gold','silver','bronze','prospect'], - (number % 5) + 1) AS loyalty_segment, - toDecimal64((number * 13) % 50000, 2) AS loyalty_points, + multiIf(number % 5 < 2, 'core', number % 5 < 4, 'mid', 'tail') AS loyalty_segment, + toDecimal64((number * 13) % 9000, 2) AS loyalty_points, now64(3) - toIntervalDay((number % 90)) AS last_visit_at, 0 AS is_deleted -FROM numbers(2000) -WHERE number % 100 < 40 -- msk slice - AND number % 5 != 0; -- 80% coverage +FROM numbers(2190) +WHERE number >= 2000 -- dealer msk slice only + AND number % 5 != 0; -- 80% coverage --- ============ ORDER HEADER (Bitrix, msk slice) ============ +-- ============ ORDER HEADER (Bitrix, msk: mp+site+B2B [0,9540)) ============ +-- channel: marketplace / d2c / b2b (generator-spec.md §2). Status flow +-- pending -> confirmed -> shipped -> delivered / cancelled, steady-state +-- weights 8/10/12/62/8 (domain.md §5.1). INSERT INTO rv.sat_order_header__bitrix__msk (order_hk, load_ts, hash_diff, record_source, order_date, channel, order_status, total_amount, is_deleted) SELECT - MD5(concat( - 'bitrix__', - multiIf(number % 100 < 40, 'msk', number % 100 < 65, 'spb', - number % 100 < 80, 'ekb', number % 100 < 90, 'dxb', 'ala'), - '__', - lpad(toString(number), 7, '0') - )) AS order_hk, - now64(3) AS load_ts, - MD5(concat(toString(number), '|hdr|v1')) AS hash_diff, - 'bitrix__msk' AS record_source, - now64(3) - toIntervalHour((number * 7) % (24 * 90)) AS order_date, - arrayElement(['web','mobile','retail','call-center'], - (number % 4) + 1) AS channel, - arrayElement(['new','paid','shipped','delivered','returned'], - (number % 5) + 1) AS order_status, - toDecimal64(500 + (number * 17) % 25000, 2) AS total_amount, - 0 AS is_deleted -FROM numbers(10000) -WHERE number % 100 < 40; -- msk slice = 4000 orders + MD5(order_bk) AS order_hk, + now64(3) AS load_ts, + MD5(concat(order_bk, '|hdr|v1')) AS hash_diff, + 'bitrix__msk' AS record_source, + now64(3) - toIntervalHour((number * 7) % (24 * 21)) AS order_date, + channel, + multiIf( + number % 100 < 8, 'pending', + number % 100 < 18, 'confirmed', + number % 100 < 30, 'shipped', + number % 100 < 92, 'delivered', + 'cancelled' + ) AS order_status, + total_amount, + 0 AS is_deleted +FROM ( + SELECT + number, + concat( + multiIf(number < 8900, 'mp__msk', number < 9180, 'site__msk', 'bitrix__msk'), + '__', lpad(toString(number), 7, '0') + ) AS order_bk, + multiIf(number < 8900, 'marketplace', number < 9180, 'd2c', 'b2b') AS channel, + multiIf( + number < 8900, toDecimal64(1500 + (number * 17) % 1501, 2), -- marketplace: 1.5k-3.0k + number < 9180, toDecimal64(2000 + (number * 23) % 3001, 2), -- D2C: 2.0k-5.0k + toDecimal64(30000 + (number * 137) % 50001, 2) -- B2B msk: 30k-80k + ) AS total_amount + FROM numbers(9540) +); --- ============ ORDER PRICING (1C, msk slice) ============ +-- ============ ORDER PRICING (1C, msk: mp+site+B2B [0,9540)) ============ +-- subtotal mirrors header.total_amount (pre-tax); RU VAT 20%. INSERT INTO rv.sat_order_pricing__1c__msk (order_hk, load_ts, hash_diff, record_source, subtotal_amount, discount_amount, tax_amount, shipping_cost, is_deleted) SELECT - MD5(concat( - 'bitrix__', - multiIf(number % 100 < 40, 'msk', number % 100 < 65, 'spb', - number % 100 < 80, 'ekb', number % 100 < 90, 'dxb', 'ala'), - '__', - lpad(toString(number), 7, '0') - )) AS order_hk, - now64(3) AS load_ts, - MD5(concat(toString(number), '|prc|v1')) AS hash_diff, - '1c__msk' AS record_source, - toDecimal64(500 + (number * 17) % 25000, 2) AS subtotal_amount, - toDecimal64((number * 3) % 1500, 2) AS discount_amount, - toDecimal64((500 + (number * 17) % 25000) * 0.20, 2) AS tax_amount, - toDecimal64(199 + (number % 5) * 100, 2) AS shipping_cost, - 0 AS is_deleted -FROM numbers(10000) -WHERE number % 100 < 40; -- msk slice = 4000 orders + MD5(order_bk) AS order_hk, + now64(3) AS load_ts, + MD5(concat(order_bk, '|prc|v1')) AS hash_diff, + '1c__msk' AS record_source, + subtotal_amount, + toDecimal64(subtotal_amount * 0.02 * (number % 4), 2) AS discount_amount, + toDecimal64(subtotal_amount * 0.20, 2) AS tax_amount, + toDecimal64(if(number < 9180, 199 + (number % 5) * 100, 500 + (number % 3) * 300), 2) AS shipping_cost, + 0 AS is_deleted +FROM ( + SELECT + number, + concat( + multiIf(number < 8900, 'mp__msk', number < 9180, 'site__msk', 'bitrix__msk'), + '__', lpad(toString(number), 7, '0') + ) AS order_bk, + multiIf( + number < 8900, toDecimal64(1500 + (number * 17) % 1501, 2), + number < 9180, toDecimal64(2000 + (number * 23) % 3001, 2), + toDecimal64(30000 + (number * 137) % 50001, 2) + ) AS subtotal_amount + FROM numbers(9540) +); diff --git a/warehouse/agentflow/dv2/satellite_seed_all_branches.sql b/warehouse/agentflow/dv2/satellite_seed_all_branches.sql index b216f7d9..73216376 100644 --- a/warehouse/agentflow/dv2/satellite_seed_all_branches.sql +++ b/warehouse/agentflow/dv2/satellite_seed_all_branches.sql @@ -1,13 +1,17 @@ --- Satellite seed extension for the non-MSK branches. +-- Satellite seed extension for the non-MSK branches (own-brand +-- kitchen-appliance importer legend — numbering matches synthetic_seed.sql). -- Idempotent via hash_diff. Apply AFTER warehouse/agentflow/dv2/satellite_seed.sql -- (which seeds the msk + dxb-personal slices); this file fills: --- * sat_customer_personal__1c__{spb, ekb, ala} +-- * sat_customer_personal__1c__{spb, ekb, ala} -- dealer bands only -- * sat_customer_loyalty__bitrix__{spb, ekb} -- dxb/ala intentionally skipped -- * sat_customer_anon__1c__{spb, ekb, dxb, ala} -- * sat_order_header__bitrix__{spb, ekb, dxb, ala} -- * sat_order_pricing__1c__{spb, ekb, dxb, ala} +-- +-- All remaining branches are B2B-only (domain.md §1: regional branches hold +-- dealer customers only; only msk fulfils retail/marketplace/D2C). --- ============ CUSTOMER PII (spb) ============ +-- ============ CUSTOMER PII (dealer spb [2190,2290)) ============ INSERT INTO rv.sat_customer_personal__1c__spb (customer_hk, load_ts, hash_diff, record_source, first_name, last_name, email, phone, birth_date, pii_flag, is_deleted) @@ -20,12 +24,12 @@ SELECT arrayElement(['Ivanov','Petrov','Sidorov','Smirnov','Volkov','Orlov','Lebedev','Sokolov'], (number % 8) + 1), concat('cust', toString(number), '@example.test'), concat('+7812', lpad(toString(number % 10000000), 7, '0')), - toDate('1960-01-01') + (number % (365 * 50)), + toDate('1960-01-01') + (number % (365 * 50)), -- dealer: birth_date always filled (§8) true, 0 -FROM numbers(2000) -WHERE number % 100 >= 40 AND number % 100 < 65; -- spb slice (25%) +FROM numbers(2290) +WHERE number >= 2190; -- dealer spb [2190,2290) --- ============ CUSTOMER PII (ekb) ============ +-- ============ CUSTOMER PII (dealer ekb [2290,2360)) ============ INSERT INTO rv.sat_customer_personal__1c__ekb (customer_hk, load_ts, hash_diff, record_source, first_name, last_name, email, phone, birth_date, pii_flag, is_deleted) @@ -40,10 +44,10 @@ SELECT concat('+7343', lpad(toString(number % 10000000), 7, '0')), toDate('1960-01-01') + (number % (365 * 50)), true, 0 -FROM numbers(2000) -WHERE number % 100 >= 65 AND number % 100 < 80; -- ekb slice (15%) +FROM numbers(2360) +WHERE number >= 2290; -- dealer ekb [2290,2360) --- ============ CUSTOMER PII (ala) ============ +-- ============ CUSTOMER PII (dealer ala [2420,2500)) ============ INSERT INTO rv.sat_customer_personal__1c__ala (customer_hk, load_ts, hash_diff, record_source, first_name, last_name, email, phone, birth_date, pii_flag, is_deleted) @@ -58,10 +62,12 @@ SELECT concat('+7727', lpad(toString(number % 10000000), 7, '0')), toDate('1965-01-01') + (number % (365 * 45)), true, 0 -FROM numbers(2000) -WHERE number % 100 >= 90; -- ala slice (10%) +FROM numbers(2500) +WHERE number >= 2420; -- dealer ala [2420,2500) --- ============ CUSTOMER LOYALTY (spb / ekb) ============ +-- ============ CUSTOMER LOYALTY (dealer spb / ekb, 80% coverage) ============ +-- dxb/ala dealers intentionally skipped: contract terms, not the bonus +-- program (domain.md §5.2, generator-spec.md §8/§12 #12). INSERT INTO rv.sat_customer_loyalty__bitrix__spb (customer_hk, load_ts, hash_diff, record_source, loyalty_segment, loyalty_points, last_visit_at, is_deleted) @@ -70,12 +76,12 @@ SELECT now64(3), MD5(concat(toString(number), '|loy|v1')), 'bitrix__spb', - arrayElement(['vip','gold','silver','bronze','prospect'], (number % 5) + 1), - toDecimal64((number * 13) % 50000, 2), + multiIf(number % 5 < 2, 'core', number % 5 < 4, 'mid', 'tail'), + toDecimal64((number * 13) % 9000, 2), now64(3) - toIntervalDay((number % 90)), 0 -FROM numbers(2000) -WHERE number % 100 >= 40 AND number % 100 < 65 +FROM numbers(2290) +WHERE number >= 2190 AND number % 5 != 0; -- 80% coverage INSERT INTO rv.sat_customer_loyalty__bitrix__ekb @@ -86,16 +92,17 @@ SELECT now64(3), MD5(concat(toString(number), '|loy|v1')), 'bitrix__ekb', - arrayElement(['vip','gold','silver','bronze','prospect'], (number % 5) + 1), - toDecimal64((number * 13) % 50000, 2), + multiIf(number % 5 < 2, 'core', number % 5 < 4, 'mid', 'tail'), + toDecimal64((number * 13) % 9000, 2), now64(3) - toIntervalDay((number % 90)), 0 -FROM numbers(2000) -WHERE number % 100 >= 65 AND number % 100 < 80 +FROM numbers(2360) +WHERE number >= 2290 AND number % 5 != 0; --- ============ ANON SATS (spb / ekb / dxb / ala) ============ --- branch helper view inlined per insert; one anon row per customer per branch. +-- ============ ANON SATS (spb / ekb / dxb / ala dealer bands) ============ +-- One anon row per dealer customer per branch. msk anon lives in +-- cold_offload_seed.sql (covers retail + dealer msk together). INSERT INTO rv.sat_customer_anon__1c__spb (customer_hk, load_ts, hash_diff, record_source, age_bucket, geo_region, customer_segment, is_deleted) @@ -106,8 +113,8 @@ SELECT arrayElement(['spb-center','spb-north','spb-south'], (number % 3) + 1), arrayElement(['vip','regular','churned','new'], (number % 4) + 1), 0 -FROM numbers(2000) -WHERE number % 100 >= 40 AND number % 100 < 65; +FROM numbers(2290) +WHERE number >= 2190; INSERT INTO rv.sat_customer_anon__1c__ekb (customer_hk, load_ts, hash_diff, record_source, @@ -119,8 +126,8 @@ SELECT arrayElement(['ekb-center','ekb-vtuz'], (number % 2) + 1), arrayElement(['vip','regular','churned','new'], (number % 4) + 1), 0 -FROM numbers(2000) -WHERE number % 100 >= 65 AND number % 100 < 80; +FROM numbers(2360) +WHERE number >= 2290; INSERT INTO rv.sat_customer_anon__1c__dxb (customer_hk, load_ts, hash_diff, record_source, @@ -132,8 +139,8 @@ SELECT arrayElement(['dxb-marina','dxb-downtown','dxb-deira'], (number % 3) + 1), arrayElement(['vip','regular','churned','new'], (number % 4) + 1), 0 -FROM numbers(2000) -WHERE number % 100 >= 80 AND number % 100 < 90; +FROM numbers(2420) +WHERE number >= 2360; INSERT INTO rv.sat_customer_anon__1c__ala (customer_hk, load_ts, hash_diff, record_source, @@ -145,26 +152,28 @@ SELECT arrayElement(['ala-medeu','ala-bostandyk','ala-almaly'], (number % 3) + 1), arrayElement(['vip','regular','churned','new'], (number % 4) + 1), 0 -FROM numbers(2000) -WHERE number % 100 >= 90; +FROM numbers(2500) +WHERE number >= 2420; --- ============ ORDER HEADER (spb / ekb / dxb / ala) ============ --- Single helper template; same order_hk derivation as synthetic_seed.sql. +-- ============ ORDER HEADER (B2B: spb / ekb / dxb / ala) ============ +-- channel = 'b2b' everywhere here (these branches carry no marketplace/D2C +-- volume). Same status-flow weights as msk (domain.md §5.1). INSERT INTO rv.sat_order_header__bitrix__spb (order_hk, load_ts, hash_diff, record_source, order_date, channel, order_status, total_amount, is_deleted) SELECT MD5(concat('bitrix__spb__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|hdr|v1')), + MD5(concat('bitrix__spb__', lpad(toString(number), 7, '0'), '|hdr|v1')), 'bitrix__spb', - now64(3) - toIntervalHour((number * 7) % (24 * 90)), - arrayElement(['web','mobile','retail','call-center'], (number % 4) + 1), - arrayElement(['new','paid','shipped','delivered','returned'], (number % 5) + 1), - toDecimal64(500 + (number * 17) % 25000, 2), + now64(3) - toIntervalHour((number * 7) % (24 * 21)), + 'b2b', + multiIf(number % 100 < 8, 'pending', number % 100 < 18, 'confirmed', + number % 100 < 30, 'shipped', number % 100 < 92, 'delivered', 'cancelled'), + toDecimal64(30000 + (number * 137) % 50001, 2), 0 -FROM numbers(10000) -WHERE number % 100 >= 40 AND number % 100 < 65; +FROM numbers(9720) +WHERE number >= 9540; -- B2B spb [9540,9720) INSERT INTO rv.sat_order_header__bitrix__ekb (order_hk, load_ts, hash_diff, record_source, @@ -172,15 +181,16 @@ INSERT INTO rv.sat_order_header__bitrix__ekb SELECT MD5(concat('bitrix__ekb__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|hdr|v1')), + MD5(concat('bitrix__ekb__', lpad(toString(number), 7, '0'), '|hdr|v1')), 'bitrix__ekb', - now64(3) - toIntervalHour((number * 7) % (24 * 90)), - arrayElement(['web','mobile','retail','call-center'], (number % 4) + 1), - arrayElement(['new','paid','shipped','delivered','returned'], (number % 5) + 1), - toDecimal64(500 + (number * 17) % 25000, 2), + now64(3) - toIntervalHour((number * 7) % (24 * 21)), + 'b2b', + multiIf(number % 100 < 8, 'pending', number % 100 < 18, 'confirmed', + number % 100 < 30, 'shipped', number % 100 < 92, 'delivered', 'cancelled'), + toDecimal64(30000 + (number * 137) % 50001, 2), 0 -FROM numbers(10000) -WHERE number % 100 >= 65 AND number % 100 < 80; +FROM numbers(9850) +WHERE number >= 9720; -- B2B ekb [9720,9850) INSERT INTO rv.sat_order_header__bitrix__dxb (order_hk, load_ts, hash_diff, record_source, @@ -188,15 +198,16 @@ INSERT INTO rv.sat_order_header__bitrix__dxb SELECT MD5(concat('bitrix__dxb__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|hdr|v1')), + MD5(concat('bitrix__dxb__', lpad(toString(number), 7, '0'), '|hdr|v1')), 'bitrix__dxb', - now64(3) - toIntervalHour((number * 7) % (24 * 90)), - arrayElement(['web','mobile','retail','call-center'], (number % 4) + 1), - arrayElement(['new','paid','shipped','delivered','returned'], (number % 5) + 1), - toDecimal64(500 + (number * 17) % 25000, 2), + now64(3) - toIntervalHour((number * 7) % (24 * 21)), + 'b2b', + multiIf(number % 100 < 8, 'pending', number % 100 < 18, 'confirmed', + number % 100 < 30, 'shipped', number % 100 < 92, 'delivered', 'cancelled'), + toDecimal64(60000 + (number * 191) % 70001, 2), -- export pallets: thinner margin, bigger tickets (§5) 0 -FROM numbers(10000) -WHERE number % 100 >= 80 AND number % 100 < 90; +FROM numbers(9925) +WHERE number >= 9850; -- B2B dxb [9850,9925) INSERT INTO rv.sat_order_header__bitrix__ala (order_hk, load_ts, hash_diff, record_source, @@ -204,32 +215,34 @@ INSERT INTO rv.sat_order_header__bitrix__ala SELECT MD5(concat('bitrix__ala__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|hdr|v1')), + MD5(concat('bitrix__ala__', lpad(toString(number), 7, '0'), '|hdr|v1')), 'bitrix__ala', - now64(3) - toIntervalHour((number * 7) % (24 * 90)), - arrayElement(['web','mobile','retail','call-center'], (number % 4) + 1), - arrayElement(['new','paid','shipped','delivered','returned'], (number % 5) + 1), - toDecimal64(500 + (number * 17) % 25000, 2), + now64(3) - toIntervalHour((number * 7) % (24 * 21)), + 'b2b', + multiIf(number % 100 < 8, 'pending', number % 100 < 18, 'confirmed', + number % 100 < 30, 'shipped', number % 100 < 92, 'delivered', 'cancelled'), + toDecimal64(25000 + (number * 151) % 45001, 2), 0 FROM numbers(10000) -WHERE number % 100 >= 90; +WHERE number >= 9925; -- B2B ala [9925,10000) --- ============ ORDER PRICING (spb / ekb / dxb / ala) ============ +-- ============ ORDER PRICING (B2B: spb / ekb / dxb / ala) ============ +-- RU VAT 20% (spb/ekb); UAE VAT 5% (dxb); KZ VAT 12% (ala). INSERT INTO rv.sat_order_pricing__1c__spb (order_hk, load_ts, hash_diff, record_source, subtotal_amount, discount_amount, tax_amount, shipping_cost, is_deleted) SELECT MD5(concat('bitrix__spb__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|prc|v1')), + MD5(concat('bitrix__spb__', lpad(toString(number), 7, '0'), '|prc|v1')), '1c__spb', - toDecimal64(500 + (number * 17) % 25000, 2), - toDecimal64((number * 3) % 1500, 2), - toDecimal64((500 + (number * 17) % 25000) * 0.20, 2), - toDecimal64(199 + (number % 5) * 100, 2), + toDecimal64(30000 + (number * 137) % 50001, 2), + toDecimal64((30000 + (number * 137) % 50001) * 0.02 * (number % 4), 2), + toDecimal64((30000 + (number * 137) % 50001) * 0.20, 2), + toDecimal64(500 + (number % 3) * 300, 2), 0 -FROM numbers(10000) -WHERE number % 100 >= 40 AND number % 100 < 65; +FROM numbers(9720) +WHERE number >= 9540; INSERT INTO rv.sat_order_pricing__1c__ekb (order_hk, load_ts, hash_diff, record_source, @@ -237,15 +250,15 @@ INSERT INTO rv.sat_order_pricing__1c__ekb SELECT MD5(concat('bitrix__ekb__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|prc|v1')), + MD5(concat('bitrix__ekb__', lpad(toString(number), 7, '0'), '|prc|v1')), '1c__ekb', - toDecimal64(500 + (number * 17) % 25000, 2), - toDecimal64((number * 3) % 1500, 2), - toDecimal64((500 + (number * 17) % 25000) * 0.20, 2), - toDecimal64(199 + (number % 5) * 100, 2), + toDecimal64(30000 + (number * 137) % 50001, 2), + toDecimal64((30000 + (number * 137) % 50001) * 0.02 * (number % 4), 2), + toDecimal64((30000 + (number * 137) % 50001) * 0.20, 2), + toDecimal64(500 + (number % 3) * 300, 2), 0 -FROM numbers(10000) -WHERE number % 100 >= 65 AND number % 100 < 80; +FROM numbers(9850) +WHERE number >= 9720; INSERT INTO rv.sat_order_pricing__1c__dxb (order_hk, load_ts, hash_diff, record_source, @@ -253,15 +266,15 @@ INSERT INTO rv.sat_order_pricing__1c__dxb SELECT MD5(concat('bitrix__dxb__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|prc|v1')), + MD5(concat('bitrix__dxb__', lpad(toString(number), 7, '0'), '|prc|v1')), '1c__dxb', - toDecimal64(500 + (number * 17) % 25000, 2), - toDecimal64((number * 3) % 1500, 2), - toDecimal64((500 + (number * 17) % 25000) * 0.05, 2), -- DXB VAT 5% - toDecimal64(199 + (number % 5) * 100, 2), + toDecimal64(60000 + (number * 191) % 70001, 2), + toDecimal64((60000 + (number * 191) % 70001) * 0.02 * (number % 4), 2), + toDecimal64((60000 + (number * 191) % 70001) * 0.05, 2), -- DXB VAT 5% + toDecimal64(500 + (number % 3) * 300, 2), 0 -FROM numbers(10000) -WHERE number % 100 >= 80 AND number % 100 < 90; +FROM numbers(9925) +WHERE number >= 9850; INSERT INTO rv.sat_order_pricing__1c__ala (order_hk, load_ts, hash_diff, record_source, @@ -269,12 +282,12 @@ INSERT INTO rv.sat_order_pricing__1c__ala SELECT MD5(concat('bitrix__ala__', lpad(toString(number), 7, '0'))), now64(3), - MD5(concat(toString(number), '|prc|v1')), + MD5(concat('bitrix__ala__', lpad(toString(number), 7, '0'), '|prc|v1')), '1c__ala', - toDecimal64(500 + (number * 17) % 25000, 2), - toDecimal64((number * 3) % 1500, 2), - toDecimal64((500 + (number * 17) % 25000) * 0.12, 2), -- KZ VAT 12% - toDecimal64(199 + (number % 5) * 100, 2), + toDecimal64(25000 + (number * 151) % 45001, 2), + toDecimal64((25000 + (number * 151) % 45001) * 0.02 * (number % 4), 2), + toDecimal64((25000 + (number * 151) % 45001) * 0.12, 2), -- KZ VAT 12% + toDecimal64(500 + (number % 3) * 300, 2), 0 FROM numbers(10000) -WHERE number % 100 >= 90; +WHERE number >= 9925; diff --git a/warehouse/agentflow/dv2/synthetic_seed.sql b/warehouse/agentflow/dv2/synthetic_seed.sql index fc83e9b5..ab64ce0b 100644 --- a/warehouse/agentflow/dv2/synthetic_seed.sql +++ b/warehouse/agentflow/dv2/synthetic_seed.sql @@ -1,128 +1,255 @@ --- Synthetic data seed for DV2.0 demo --- Generates: 6 stores, 2000 customers, 800 products, 10000 orders, ~25000 line items --- Fix: MD5() already returns FixedString(16); do NOT wrap in unhex(). +-- Synthetic data seed for DV2.0 demo — own-brand kitchen-appliance importer +-- legend (docs/domain.md, docs/generator-spec.md). Numbers mirror +-- warehouse/agentflow/dv2/reference/legend.py so the seed and the §12 +-- invariant tests can't silently drift apart. +-- +-- Customer numbering (hub_customer, 2,500 = 2,000 retail + 500 dealers, +-- generator-spec.md §7/§11): contiguous bands, not modulo slicing. +-- 0..1999 retail (msk jurisdiction only) record_source 1c__msk +-- 2000..2189 dealer msk (190) record_source 1c__msk +-- 2190..2289 dealer spb (100) record_source 1c__spb +-- 2290..2359 dealer ekb (70) record_source 1c__ekb +-- 2360..2419 dealer dxb (60) record_source 1c__dxb +-- 2420..2499 dealer ala (80) record_source 1c__ala +-- +-- Order numbering (hub_order, 10,000 ≈ 5.1 baseline days, §11): +-- 0..8899 marketplace (8,900) record_source mp__msk +-- 8900..9179 D2C site (280) record_source site__msk +-- 9180..9539 B2B msk (360) record_source bitrix__msk +-- 9540..9719 B2B spb (180) record_source bitrix__spb +-- 9720..9849 B2B ekb (130) record_source bitrix__ekb +-- 9850..9924 B2B dxb (75) record_source bitrix__dxb +-- 9925..9999 B2B ala (75) record_source bitrix__ala +-- +-- Fix (kept from the original seed): MD5() already returns FixedString(16); +-- do NOT wrap in unhex(). -- ============ HUBS ============ --- 6 stores (master records) +-- 6 stores (master records) — footprint unchanged (domain.md §1). INSERT INTO rv.hub_store (store_hk, store_bk, load_ts, record_source) SELECT MD5(store_code), store_code, now64(), '1c__global' FROM (SELECT arrayJoin(['msk-01','msk-02','spb-01','ekb-01','dxb-01','ala-01']) AS store_code); --- 2000 customers distributed across branches +-- 2,500 customers: 2,000 retail (msk) + 500 dealers banded by branch. INSERT INTO rv.hub_customer (customer_hk, customer_bk, load_ts, record_source) SELECT MD5(toString(number)), concat('CUST-', lpad(toString(number), 6, '0')), now64(), multiIf( - number % 100 < 40, '1c__msk', - number % 100 < 65, '1c__spb', - number % 100 < 80, '1c__ekb', - number % 100 < 90, '1c__dxb', + number < 2190, '1c__msk', + number < 2290, '1c__spb', + number < 2360, '1c__ekb', + number < 2420, '1c__dxb', '1c__ala' ) -FROM numbers(2000); +FROM numbers(2500); --- 800 product SKUs +-- 160 kitchen-appliance SKUs (generator-spec.md §3), centrally managed catalog. INSERT INTO rv.hub_product (product_hk, product_bk, load_ts, record_source) SELECT MD5(sku), sku, now64(), '1c__msk' -FROM (SELECT concat('SKU-', lpad(toString(number), 5, '0')) AS sku FROM numbers(800)); +FROM (SELECT concat('SKU-', lpad(toString(number), 5, '0')) AS sku FROM numbers(160)); --- 10000 orders +-- 10,000 orders: 8,900 marketplace + 280 D2C site + 820 B2B (per-branch +-- msk 360 / spb 180 / ekb 130 / dxb 75 / ala 75). INSERT INTO rv.hub_order (order_hk, order_bk, load_ts, record_source) -SELECT - MD5(order_id), - order_id, - now64(), - multiIf( - number % 100 < 40, '1c__msk', - number % 100 < 65, '1c__spb', - number % 100 < 80, '1c__ekb', - number % 100 < 90, '1c__dxb', - '1c__ala' - ) +SELECT MD5(order_bk), order_bk, now64(), record_source FROM ( - SELECT number, - concat( - 'bitrix__', - multiIf( - number % 100 < 40, 'msk', - number % 100 < 65, 'spb', - number % 100 < 80, 'ekb', - number % 100 < 90, 'dxb', - 'ala' - ), - '__', - lpad(toString(number), 7, '0') - ) AS order_id + SELECT + number, + multiIf( + number < 8900, 'mp__msk', + number < 9180, 'site__msk', + number < 9540, 'bitrix__msk', + number < 9720, 'bitrix__spb', + number < 9850, 'bitrix__ekb', + number < 9925, 'bitrix__dxb', + 'bitrix__ala' + ) AS record_source, + concat(record_source, '__', lpad(toString(number), 7, '0')) AS order_bk FROM numbers(10000) ); +-- 160 SKU-level GS1/Chestny Znak marking-code templates (one per product, +-- 'issued' — a template registration used repeatedly, not a per-unit scan +-- state) + ~12,000 per-unit code sample (≈ one container), status split +-- issued 25% / in_circulation 60% / withdrawn 15% (§11). +INSERT INTO rv.hub_marking_code (marking_code_hk, marking_code_bk, load_ts, record_source) +SELECT MD5(marking_code_bk), marking_code_bk, now64(), record_source +FROM ( + SELECT concat('CZ-SKU-', lpad(toString(number), 5, '0')) AS marking_code_bk, '1c__global' AS record_source + FROM numbers(160) + UNION ALL + SELECT + concat('CZU-', lpad(toString(number % 160), 5, '0'), '-', lpad(toString(intDiv(number, 160)), 7, '0')) AS marking_code_bk, + '1c__global' AS record_source + FROM numbers(12000) +); + -- ============ LINKS ============ --- lnk_order_customer (1:1 per order, inline computation) +-- lnk_order_customer: marketplace/site orders draw from the retail pool; +-- B2B orders draw from the dealer pool of their own branch. INSERT INTO rv.lnk_order_customer (link_hk, order_hk, customer_hk, load_ts, record_source) SELECT - MD5(concat(toString(number), '|', toString(cityHash64(number) % 2000))), - MD5(concat( - 'bitrix__', - multiIf(number % 100 < 40,'msk',number % 100 < 65,'spb',number % 100 < 80,'ekb',number % 100 < 90,'dxb','ala'), - '__', - lpad(toString(number), 7, '0') - )), - MD5(toString(cityHash64(number) % 2000)), + MD5(concat(order_bk, '|', toString(customer_number))), + MD5(order_bk), + MD5(toString(customer_number)), now64(), + record_source +FROM ( + SELECT + number, multiIf( - number % 100 < 40, 'bitrix__msk', - number % 100 < 65, 'bitrix__spb', - number % 100 < 80, 'bitrix__ekb', - number % 100 < 90, 'bitrix__dxb', + number < 8900, 'mp__msk', + number < 9180, 'site__msk', + number < 9540, 'bitrix__msk', + number < 9720, 'bitrix__spb', + number < 9850, 'bitrix__ekb', + number < 9925, 'bitrix__dxb', 'bitrix__ala' - ) -FROM numbers(10000); + ) AS record_source, + concat(record_source, '__', lpad(toString(number), 7, '0')) AS order_bk, + multiIf( + number < 9180, cityHash64(number) % 2000, -- mp/site -> retail [0,2000) + number < 9540, 2000 + (cityHash64(number) % 190), -- B2B msk -> dealer [2000,2190) + number < 9720, 2190 + (cityHash64(number) % 100), -- B2B spb -> dealer [2190,2290) + number < 9850, 2290 + (cityHash64(number) % 70), -- B2B ekb -> dealer [2290,2360) + number < 9925, 2360 + (cityHash64(number) % 60), -- B2B dxb -> dealer [2360,2420) + 2420 + (cityHash64(number) % 80) -- B2B ala -> dealer [2420,2500) + ) AS customer_number + FROM numbers(10000) +); --- lnk_order_product (~2.5 line items per order via ARRAY JOIN range) +-- lnk_order_product: line-count and product mix follow order shapes (§2). +-- Marketplace/D2C lean toward the ABC bestseller band (top 24 SKUs, §3); +-- B2B picks uniformly across the full 160-SKU catalog. INSERT INTO rv.lnk_order_product (link_hk, order_hk, product_hk, load_ts, record_source) SELECT - MD5(concat(toString(number), '|', toString(p))), - MD5(concat( - 'bitrix__', - multiIf(number % 100 < 40,'msk',number % 100 < 65,'spb',number % 100 < 80,'ekb',number % 100 < 90,'dxb','ala'), - '__', - lpad(toString(number), 7, '0') - )), + MD5(concat(order_bk, '|', toString(p))), + MD5(order_bk), MD5(concat('SKU-', lpad(toString(p), 5, '0'))), now64(), + '1c__msk' +FROM ( + SELECT + number, + concat( + multiIf( + number < 8900, 'mp__msk', + number < 9180, 'site__msk', + number < 9540, 'bitrix__msk', + number < 9720, 'bitrix__spb', + number < 9850, 'bitrix__ekb', + number < 9925, 'bitrix__dxb', + 'bitrix__ala' + ), + '__', + lpad(toString(number), 7, '0') + ) AS order_bk, multiIf( - number % 100 < 40, '1c__msk', - number % 100 < 65, '1c__spb', - number % 100 < 80, '1c__ekb', - number % 100 < 90, '1c__dxb', - '1c__ala' - ) -FROM numbers(10000) -ARRAY JOIN arrayMap(i -> cityHash64(number * 31 + i) % 800, range(1 + (cityHash64(number) % 4))) AS p; + number < 8900, if(number % 20 = 0, 2, 1), -- mp: 95%x1 + 5%x2, avg 1.05 + number < 9180, multiIf(number % 100 < 75, 1, number % 100 < 95, 2, 3), -- site: avg 1.30 + number < 9540, 3 + (cityHash64(number) % 8), -- B2B RU: 3-10, avg 6.5 + number < 9720, 3 + (cityHash64(number) % 8), + number < 9850, 3 + (cityHash64(number) % 8), + number < 9925, 4 + (cityHash64(number) % 9), -- B2B dxb: 4-12, avg 8 + 3 + (cityHash64(number) % 6) -- B2B ala: 3-8, avg 5.5 + ) AS line_count + FROM numbers(10000) +) +ARRAY JOIN arrayMap( + i -> if( + number < 9180, + multiIf( + cityHash64(number * 31 + i) % 100 < 55, cityHash64(number * 37 + i) % 24, + cityHash64(number * 31 + i) % 100 < 90, 24 + (cityHash64(number * 37 + i) % 56), + 80 + (cityHash64(number * 37 + i) % 80) + ), + cityHash64(number * 31 + i) % 160 + ), + range(line_count) +) AS p; + +-- sat_marking_code_gs1__1c__global: status for both the 160 SKU-level +-- templates ('issued') and the ~12,000 per-unit sample (25/60/15 split, +-- §11). gs1_gtin here is a synthetic, unverified code stem — not the +-- reference package's genuine GS1-checked GTIN (which lives in the +-- ref__global satellite via reference/build.py); serial_number carries the +-- per-unit distinction. +INSERT INTO rv.sat_marking_code_gs1__1c__global + (marking_code_hk, load_ts, hash_diff, record_source, + gs1_gtin, serial_number, marking_status, is_deleted) +SELECT + MD5(marking_code_bk), now64(3), MD5(concat(marking_code_bk, '|gs1|v1')), '1c__global', + gs1_gtin, serial_number, marking_status, 0 +FROM ( + SELECT + concat('CZ-SKU-', lpad(toString(number), 5, '0')) AS marking_code_bk, + concat(toString(460 + (number % 10)), lpad(toString(200000 + number * 617), 9, '0')) AS gs1_gtin, + CAST(NULL, 'Nullable(String)') AS serial_number, + 'issued' AS marking_status + FROM numbers(160) + UNION ALL + SELECT + concat('CZU-', lpad(toString(number % 160), 5, '0'), '-', lpad(toString(intDiv(number, 160)), 7, '0')) AS marking_code_bk, + concat(toString(460 + ((number % 160) % 10)), lpad(toString(200000 + (number % 160) * 617), 9, '0')) AS gs1_gtin, + lpad(toString(intDiv(number, 160)), 7, '0') AS serial_number, + multiIf(number % 100 < 25, 'issued', number % 100 < 85, 'in_circulation', 'withdrawn') AS marking_status + FROM numbers(12000) +); --- lnk_order_store +-- lnk_product_marking: links both the 160 SKU-level templates and the +-- ~12,000 per-unit codes to their product (no order dimension — this link +-- is product<->marking-code traceability, independent of any one sale). +INSERT INTO rv.lnk_product_marking (link_hk, product_hk, marking_code_hk, load_ts, record_source) +SELECT + MD5(concat(product_bk, '|', marking_code_bk)), + MD5(product_bk), + MD5(marking_code_bk), + now64(), + '1c__global' +FROM ( + SELECT concat('SKU-', lpad(toString(number), 5, '0')) AS product_bk, + concat('CZ-SKU-', lpad(toString(number), 5, '0')) AS marking_code_bk + FROM numbers(160) + UNION ALL + SELECT concat('SKU-', lpad(toString(number % 160), 5, '0')) AS product_bk, + concat('CZU-', lpad(toString(number % 160), 5, '0'), '-', lpad(toString(intDiv(number, 160)), 7, '0')) AS marking_code_bk + FROM numbers(12000) +); + +-- lnk_order_store: msk fulfils marketplace + D2C + its own B2B (central +-- warehouse, alternating msk-01/msk-02); regional branches fulfil their own +-- B2B only (domain.md §1 footprint). INSERT INTO rv.lnk_order_store (link_hk, order_hk, store_hk, load_ts, record_source) SELECT - MD5(concat(toString(number), '|', store_code)), - MD5(concat( - 'bitrix__', - multiIf(number % 100 < 40,'msk',number % 100 < 65,'spb',number % 100 < 80,'ekb',number % 100 < 90,'dxb','ala'), - '__', - lpad(toString(number), 7, '0') - )), + MD5(concat(order_bk, '|', store_code)), + MD5(order_bk), MD5(store_code), now64(), '1c__global' FROM ( - SELECT number, + SELECT + number, + concat( + multiIf( + number < 8900, 'mp__msk', + number < 9180, 'site__msk', + number < 9540, 'bitrix__msk', + number < 9720, 'bitrix__spb', + number < 9850, 'bitrix__ekb', + number < 9925, 'bitrix__dxb', + 'bitrix__ala' + ), + '__', + lpad(toString(number), 7, '0') + ) AS order_bk, multiIf( - number % 100 < 40, if(number % 2 = 0, 'msk-01', 'msk-02'), - number % 100 < 65, 'spb-01', - number % 100 < 80, 'ekb-01', - number % 100 < 90, 'dxb-01', + number < 9540, if(number % 2 = 0, 'msk-01', 'msk-02'), + number < 9720, 'spb-01', + number < 9850, 'ekb-01', + number < 9925, 'dxb-01', 'ala-01' ) AS store_code FROM numbers(10000)