diff --git a/.gitignore b/.gitignore index e73a8cc..7613973 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ *.tmp .superpowers/ out/ +__pycache__/ +*.pyc diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index 606fd01..c2cb4ae 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -278,3 +278,7 @@ README прогона, точные субсчета внутри перечис | 14 | 12.11.2026 | Скрипт сборки шаблонов ББ/ОФР называется `build_forms_templates.py` (как в плане; §6.2 называл `build_templates.py`) (R-2) | отличать от `build_osv_style.py` | нет | | 15 | 12.11.2026 | Префикс ГК — буквы латиницы и кириллицы + цифры, 2–12 символов (`config._PREFIX_RE`) | §3 говорит «буквы/цифры» без уточнения алфавита; название группы русское | префикс с кириллицей не пройдёт, если ядро ждёт только латиницу — сузить регулярное выражение | | 16 | 12.11.2026 | Зерно `rng_for`/`stable_hash`: первые 8 байт SHA-256 читаются как big-endian | §8/§3 не фиксируют порядок байт; важна только стабильность между запусками | смена порядка меняет все наборы для того же seed одноразово — перегенерировать образцы | +| 17 | 12.11.2026 | `synthgen/dictionary/` — пакет с `__init__.py` (там `Org`, `load`), модуля `dictionary.py` нет | `python -m synthgen.dictionary.build` требует пакет; `dictionary.py` рядом с каталогом `dictionary/` давал бы конфликт имён; импорт `synthgen.dictionary` не меняется | нет | +| 18 | 12.11.2026 | Вариантное написание члена группы в ОСВ без ИНН: перестановка `<корень> <форма>` без кавычек (`group.variant_spelling`); вариант выбирает `rng_for(seed, "group", prefix)` среди остальных членов | нужно написание, которое не совпадёт с `name` и `name_1c` посимвольно, но читается человеком; точный механизм сопоставления ядра по имени не проверялся | если ядро нормализует порядок слов и сопоставит автоматически — ручного сопоставления не будет, поменять правило варианта | +| 19 | 12.11.2026 | Доли головной: дочерняя без миноритария — `1.00`; у 1–2 дочерних (при `group_size=2` — у единственной) доля 0.51–0.99, миноритарий — внешняя организация словаря (юрлицо не банк) | §4 тестов требует `head_share ∈ [0.51, 1.00]` и миноритария у 1–2; миноритарий остаётся внешним контрагентом, источник долей — только данные группы | миноритарий не как контрагент в ОСВ — не будет связи, исключить из `externals` | +| 20 | 12.11.2026 | В ЕЦП «Базовый РФ ГК в CRM» — `Ростовский региональный филиал`, `Тип` — `ЮЛ`, как в #35; имя группы в колонке «Группа компаний» — `ГК ` (в #35 было без «ГК») | импорт ядра принимает любые значения (обязательны ИНН, iD ГК, Группа компаний, Организация, Тип); `ГК ` задано §4.2 | нет | diff --git a/synthgen/dictionary/__init__.py b/synthgen/dictionary/__init__.py new file mode 100644 index 0000000..10e0cab --- /dev/null +++ b/synthgen/dictionary/__init__.py @@ -0,0 +1,34 @@ +"""Словарь вымышленных организаций (спека §4.1): чтение закоммиченного `companies.csv`. + +Это пакет (а не модуль `dictionary.py`), чтобы работал `python -m synthgen.dictionary.build` (решение 17). +""" + +import csv +from dataclasses import dataclass +from pathlib import Path + +CSV_PATH = Path(__file__).with_name("companies.csv") +HEADER = ["name", "form", "inn", "kpp", "kind"] + + +@dataclass(frozen=True) +class Org: + """Организация словаря. `name` — как её пишет 1С: `Северная Нива ООО`, `Аверина Е.И. ИП`.""" + + name: str + form: str # ООО | АО | ПАО | ИП + inn: str + kpp: str # у ИП пусто + kind: str # отрасль или «банк» + + @property + def root(self) -> str: + """Имя без формы: `Северная Нива`.""" + return self.name.rsplit(" ", 1)[0] + + +def load(path: Path | None = None) -> list[Org]: + """Организации словаря в порядке файла.""" + with (path or CSV_PATH).open(encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh, delimiter=";") + return [Org(r["name"], r["form"], r["inn"], r["kpp"], r["kind"]) for r in reader] diff --git a/synthgen/dictionary/build.py b/synthgen/dictionary/build.py new file mode 100644 index 0000000..9b2fd70 --- /dev/null +++ b/synthgen/dictionary/build.py @@ -0,0 +1,109 @@ +"""Сборка `companies.csv` (спека §4.1): 400 вымышленных организаций без случайности. + +Запуск из корня репозитория: `python -m synthgen.dictionary.build` — перезаписывает `companies.csv`. +Имена — перебор «прилагательное × существительное» по отраслям с фиксированным шагом, ИНН — подряд от 0100001. +""" + +import csv +from pathlib import Path + +from synthgen import inn +from synthgen.dictionary import CSV_PATH, HEADER, Org + +FIRST_SEQ = 100001 # ИНН-номер 0100001 +PER_SECTOR = 55 +IP_COUNT = 40 +BANK_COUNT = 30 + +# (основа, окончания для m/f/n) +_ADJ = ["Лазурн", "Янтарн", "Изумрудн", "Рубинов", "Серебрян", "Заревн", "Лунн", "Северн"] +_ENDINGS = {"m": "ый", "f": "ая", "n": "ое"} + +# отрасль → существительные (слово, род) +_SECTORS: dict[str, list[tuple[str, str]]] = { + "агро": [("Нива", "f"), ("Колос", "m"), ("Урожай", "m"), ("Пашня", "f"), ("Зерно", "n"), + ("Луг", "m"), ("Сад", "m"), ("Борозда", "f"), ("Поле", "n"), ("Амбар", "m")], + "торговля": [("Рынок", "m"), ("Склад", "m"), ("Лавка", "f"), ("Ярмарка", "f"), ("Базар", "m"), + ("Торг", "m"), ("Магазин", "m"), ("Прилавок", "m"), ("Пассаж", "m"), ("Товар", "m")], + "логистика": [("Путь", "m"), ("Маршрут", "m"), ("Фургон", "m"), ("Трасса", "f"), ("Обоз", "m"), + ("Тягач", "m"), ("Рейс", "m"), ("Перевал", "m"), ("Караван", "m"), ("Магистраль", "f")], + "производство": [("Завод", "m"), ("Цех", "m"), ("Станок", "m"), ("Мельница", "f"), ("Пекарня", "f"), + ("Кузница", "f"), ("Фабрика", "f"), ("Горн", "m"), ("Верфь", "f"), ("Мануфактура", "f")], + "услуги": [("Сервис", "m"), ("Бюро", "n"), ("Центр", "m"), ("Консалт", "m"), ("Профи", "m"), + ("Партнёр", "m"), ("Совет", "m"), ("Контора", "f"), ("Мастерская", "f"), ("Приём", "m")], + "строительство": [("Фундамент", "m"), ("Кирпич", "m"), ("Монолит", "m"), ("Каркас", "m"), ("Проект", "m"), + ("Стройка", "f"), ("Бастион", "m"), ("Зодчий", "m"), ("Арка", "f"), ("Мост", "m")], +} + +_BANK_WORDS = ["Банк", "Кредитный Банк", "Деловой Банк", "Земельный Банк"] + +_SURNAMES = [ + "Авдонина", "Бражников", "Ветрова", "Голубев", "Дроздова", "Ермаков", "Жарова", "Зуев", "Игнатова", "Копылов", + "Лаврова", "Мартынов", "Норина", "Овчаров", "Пименова", "Рябов", "Сорокина", "Тарасов", "Устинова", "Фролов", + "Хромова", "Цветков", "Чернова", "Шаров", "Щукина", "Юдин", "Яковлева", "Баранов", "Волкова", "Гусев", + "Денисова", "Елисеев", "Зайцева", "Исаев", "Калинина", "Леонов", "Маслова", "Назаров", "Орлова", "Панов", +] +_INITIALS = ["Е.И.", "А.П.", "Н.С.", "В.Д.", "О.А.", "М.В.", "И.Н.", "С.К.", "Т.Р.", "Д.Л."] +_IP_KINDS = ["услуги", "торговля", "логистика", "агро", "строительство", "производство"] + + +def _kpp(seq: int) -> str: + """КПП: `00` + двузначный номер + `01001` (как в #35).""" + return f"00{seq % 100:02d}01001" + + +def _legal_form(idx: int) -> str: + if idx % 17 == 0: + return "ПАО" + return "АО" if idx % 5 == 0 else "ООО" + + +def build_records() -> list[Org]: + """400 записей: 330 юрлиц по отраслям (с шагом 3 по сетке 80 пар), 40 ИП, 30 банков.""" + orgs: list[Org] = [] + seq = FIRST_SEQ + + def add(name: str, form: str, kind: str) -> None: + nonlocal seq + if form == "ИП": + orgs.append(Org(name, form, inn.make_inn12(seq), "", kind)) + else: + orgs.append(Org(name, form, inn.make_inn10(seq), _kpp(seq), kind)) + seq += 1 + + legal_idx = 0 + for j in range(PER_SECTOR): + pair = (j * 3) % (len(_ADJ) * 10) # gcd(3, 80) = 1 — пары не повторяются + adj_stem = _ADJ[pair % len(_ADJ)] + for kind, nouns in _SECTORS.items(): + noun, gender = nouns[pair // len(_ADJ)] + legal_idx += 1 + form = _legal_form(legal_idx) + add(f"{adj_stem}{_ENDINGS[gender]} {noun} {form}", form, kind) + for j in range(IP_COUNT): + add(f"{_SURNAMES[j]} {_INITIALS[j % len(_INITIALS)]} ИП", "ИП", _IP_KINDS[j % len(_IP_KINDS)]) + for j in range(BANK_COUNT): + stem = _ADJ[j % len(_ADJ)] + words = _BANK_WORDS[(j // len(_ADJ)) % len(_BANK_WORDS)] + form = "ПАО" if j % 2 else "АО" + add(f"{stem}ый {words} {form}", form, "банк") + return orgs + + +def write_csv(path: Path, orgs: list[Org] | None = None) -> None: + rows = orgs if orgs is not None else build_records() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.writer(fh, delimiter=";", lineterminator="\n") + writer.writerow(HEADER) + for o in rows: + writer.writerow([o.name, o.form, o.inn, o.kpp, o.kind]) + + +def main() -> None: + write_csv(CSV_PATH) + print(f"записано {CSV_PATH}") + + +if __name__ == "__main__": + main() diff --git a/synthgen/dictionary/companies.csv b/synthgen/dictionary/companies.csv new file mode 100644 index 0000000..3c373a8 --- /dev/null +++ b/synthgen/dictionary/companies.csv @@ -0,0 +1,401 @@ +name;form;inn;kpp;kind +Лазурная Нива ООО;ООО;0001000010;000101001;агро +Лазурный Рынок ООО;ООО;0001000028;000201001;торговля +Лазурный Путь ООО;ООО;0001000035;000301001;логистика +Лазурный Завод ООО;ООО;0001000042;000401001;производство +Лазурный Сервис АО;АО;0001000050;000501001;услуги +Лазурный Фундамент ООО;ООО;0001000067;000601001;строительство +Рубиновая Нива ООО;ООО;0001000074;000701001;агро +Рубиновый Рынок ООО;ООО;0001000081;000801001;торговля +Рубиновый Путь ООО;ООО;0001000099;000901001;логистика +Рубиновый Завод АО;АО;0001000109;001001001;производство +Рубиновый Сервис ООО;ООО;0001000116;001101001;услуги +Рубиновый Фундамент ООО;ООО;0001000123;001201001;строительство +Лунная Нива ООО;ООО;0001000130;001301001;агро +Лунный Рынок ООО;ООО;0001000148;001401001;торговля +Лунный Путь АО;АО;0001000155;001501001;логистика +Лунный Завод ООО;ООО;0001000162;001601001;производство +Лунный Сервис ПАО;ПАО;0001000170;001701001;услуги +Лунный Фундамент ООО;ООО;0001000187;001801001;строительство +Янтарный Колос ООО;ООО;0001000194;001901001;агро +Янтарный Склад АО;АО;0001000204;002001001;торговля +Янтарный Маршрут ООО;ООО;0001000211;002101001;логистика +Янтарный Цех ООО;ООО;0001000229;002201001;производство +Янтарное Бюро ООО;ООО;0001000236;002301001;услуги +Янтарный Кирпич ООО;ООО;0001000243;002401001;строительство +Серебряный Колос АО;АО;0001000250;002501001;агро +Серебряный Склад ООО;ООО;0001000268;002601001;торговля +Серебряный Маршрут ООО;ООО;0001000275;002701001;логистика +Серебряный Цех ООО;ООО;0001000282;002801001;производство +Серебряное Бюро ООО;ООО;0001000290;002901001;услуги +Серебряный Кирпич АО;АО;0001000300;003001001;строительство +Северный Колос ООО;ООО;0001000317;003101001;агро +Северный Склад ООО;ООО;0001000324;003201001;торговля +Северный Маршрут ООО;ООО;0001000331;003301001;логистика +Северный Цех ПАО;ПАО;0001000349;003401001;производство +Северное Бюро АО;АО;0001000356;003501001;услуги +Северный Кирпич ООО;ООО;0001000363;003601001;строительство +Изумрудный Урожай ООО;ООО;0001000370;003701001;агро +Изумрудная Лавка ООО;ООО;0001000388;003801001;торговля +Изумрудный Фургон ООО;ООО;0001000395;003901001;логистика +Изумрудный Станок АО;АО;0001000405;004001001;производство +Изумрудный Центр ООО;ООО;0001000412;004101001;услуги +Изумрудный Монолит ООО;ООО;0001000420;004201001;строительство +Заревный Урожай ООО;ООО;0001000437;004301001;агро +Заревная Лавка ООО;ООО;0001000444;004401001;торговля +Заревный Фургон АО;АО;0001000451;004501001;логистика +Заревный Станок ООО;ООО;0001000469;004601001;производство +Заревный Центр ООО;ООО;0001000476;004701001;услуги +Заревный Монолит ООО;ООО;0001000483;004801001;строительство +Лазурная Пашня ООО;ООО;0001000490;004901001;агро +Лазурная Ярмарка АО;АО;0001000500;005001001;торговля +Лазурная Трасса ПАО;ПАО;0001000518;005101001;логистика +Лазурная Мельница ООО;ООО;0001000525;005201001;производство +Лазурный Консалт ООО;ООО;0001000532;005301001;услуги +Лазурный Каркас ООО;ООО;0001000540;005401001;строительство +Рубиновая Пашня АО;АО;0001000557;005501001;агро +Рубиновая Ярмарка ООО;ООО;0001000564;005601001;торговля +Рубиновая Трасса ООО;ООО;0001000571;005701001;логистика +Рубиновая Мельница ООО;ООО;0001000589;005801001;производство +Рубиновый Консалт ООО;ООО;0001000596;005901001;услуги +Рубиновый Каркас АО;АО;0001000606;006001001;строительство +Лунная Пашня ООО;ООО;0001000613;006101001;агро +Лунная Ярмарка ООО;ООО;0001000620;006201001;торговля +Лунная Трасса ООО;ООО;0001000638;006301001;логистика +Лунная Мельница ООО;ООО;0001000645;006401001;производство +Лунный Консалт АО;АО;0001000652;006501001;услуги +Лунный Каркас ООО;ООО;0001000660;006601001;строительство +Янтарное Зерно ООО;ООО;0001000677;006701001;агро +Янтарный Базар ПАО;ПАО;0001000684;006801001;торговля +Янтарный Обоз ООО;ООО;0001000691;006901001;логистика +Янтарная Пекарня АО;АО;0001000701;007001001;производство +Янтарный Профи ООО;ООО;0001000719;007101001;услуги +Янтарный Проект ООО;ООО;0001000726;007201001;строительство +Серебряное Зерно ООО;ООО;0001000733;007301001;агро +Серебряный Базар ООО;ООО;0001000740;007401001;торговля +Серебряный Обоз АО;АО;0001000758;007501001;логистика +Серебряная Пекарня ООО;ООО;0001000765;007601001;производство +Серебряный Профи ООО;ООО;0001000772;007701001;услуги +Серебряный Проект ООО;ООО;0001000780;007801001;строительство +Северное Зерно ООО;ООО;0001000797;007901001;агро +Северный Базар АО;АО;0001000807;008001001;торговля +Северный Обоз ООО;ООО;0001000814;008101001;логистика +Северная Пекарня ООО;ООО;0001000821;008201001;производство +Северный Профи ООО;ООО;0001000839;008301001;услуги +Северный Проект ООО;ООО;0001000846;008401001;строительство +Изумрудный Луг ПАО;ПАО;0001000853;008501001;агро +Изумрудный Торг ООО;ООО;0001000860;008601001;торговля +Изумрудный Тягач ООО;ООО;0001000878;008701001;логистика +Изумрудная Кузница ООО;ООО;0001000885;008801001;производство +Изумрудный Партнёр ООО;ООО;0001000892;008901001;услуги +Изумрудная Стройка АО;АО;0001000902;009001001;строительство +Заревный Луг ООО;ООО;0001000910;009101001;агро +Заревный Торг ООО;ООО;0001000927;009201001;торговля +Заревный Тягач ООО;ООО;0001000934;009301001;логистика +Заревная Кузница ООО;ООО;0001000941;009401001;производство +Заревный Партнёр АО;АО;0001000959;009501001;услуги +Заревная Стройка ООО;ООО;0001000966;009601001;строительство +Лазурный Сад ООО;ООО;0001000973;009701001;агро +Лазурный Магазин ООО;ООО;0001000980;009801001;торговля +Лазурный Рейс ООО;ООО;0001000998;009901001;логистика +Лазурная Фабрика АО;АО;0001001007;000001001;производство +Лазурный Совет ООО;ООО;0001001014;000101001;услуги +Лазурный Бастион ПАО;ПАО;0001001021;000201001;строительство +Рубиновый Сад ООО;ООО;0001001039;000301001;агро +Рубиновый Магазин ООО;ООО;0001001046;000401001;торговля +Рубиновый Рейс АО;АО;0001001053;000501001;логистика +Рубиновая Фабрика ООО;ООО;0001001060;000601001;производство +Рубиновый Совет ООО;ООО;0001001078;000701001;услуги +Рубиновый Бастион ООО;ООО;0001001085;000801001;строительство +Лунный Сад ООО;ООО;0001001092;000901001;агро +Лунный Магазин АО;АО;0001001102;001001001;торговля +Лунный Рейс ООО;ООО;0001001110;001101001;логистика +Лунная Фабрика ООО;ООО;0001001127;001201001;производство +Лунный Совет ООО;ООО;0001001134;001301001;услуги +Лунный Бастион ООО;ООО;0001001141;001401001;строительство +Янтарная Борозда АО;АО;0001001159;001501001;агро +Янтарный Прилавок ООО;ООО;0001001166;001601001;торговля +Янтарный Перевал ООО;ООО;0001001173;001701001;логистика +Янтарный Горн ООО;ООО;0001001180;001801001;производство +Янтарная Контора ПАО;ПАО;0001001198;001901001;услуги +Янтарный Зодчий АО;АО;0001001208;002001001;строительство +Серебряная Борозда ООО;ООО;0001001215;002101001;агро +Серебряный Прилавок ООО;ООО;0001001222;002201001;торговля +Серебряный Перевал ООО;ООО;0001001230;002301001;логистика +Серебряный Горн ООО;ООО;0001001247;002401001;производство +Серебряная Контора АО;АО;0001001254;002501001;услуги +Серебряный Зодчий ООО;ООО;0001001261;002601001;строительство +Северная Борозда ООО;ООО;0001001279;002701001;агро +Северный Прилавок ООО;ООО;0001001286;002801001;торговля +Северный Перевал ООО;ООО;0001001293;002901001;логистика +Северный Горн АО;АО;0001001303;003001001;производство +Северная Контора ООО;ООО;0001001310;003101001;услуги +Северный Зодчий ООО;ООО;0001001328;003201001;строительство +Изумрудное Поле ООО;ООО;0001001335;003301001;агро +Изумрудный Пассаж ООО;ООО;0001001342;003401001;торговля +Изумрудный Караван АО;АО;0001001350;003501001;логистика +Изумрудная Верфь ПАО;ПАО;0001001367;003601001;производство +Изумрудная Мастерская ООО;ООО;0001001374;003701001;услуги +Изумрудная Арка ООО;ООО;0001001381;003801001;строительство +Заревное Поле ООО;ООО;0001001399;003901001;агро +Заревный Пассаж АО;АО;0001001409;004001001;торговля +Заревный Караван ООО;ООО;0001001416;004101001;логистика +Заревная Верфь ООО;ООО;0001001423;004201001;производство +Заревная Мастерская ООО;ООО;0001001430;004301001;услуги +Заревная Арка ООО;ООО;0001001448;004401001;строительство +Лазурный Амбар АО;АО;0001001455;004501001;агро +Лазурный Товар ООО;ООО;0001001462;004601001;торговля +Лазурная Магистраль ООО;ООО;0001001470;004701001;логистика +Лазурная Мануфактура ООО;ООО;0001001487;004801001;производство +Лазурный Приём ООО;ООО;0001001494;004901001;услуги +Лазурный Мост АО;АО;0001001504;005001001;строительство +Рубиновый Амбар ООО;ООО;0001001511;005101001;агро +Рубиновый Товар ООО;ООО;0001001529;005201001;торговля +Рубиновая Магистраль ПАО;ПАО;0001001536;005301001;логистика +Рубиновая Мануфактура ООО;ООО;0001001543;005401001;производство +Рубиновый Приём АО;АО;0001001550;005501001;услуги +Рубиновый Мост ООО;ООО;0001001568;005601001;строительство +Лунный Амбар ООО;ООО;0001001575;005701001;агро +Лунный Товар ООО;ООО;0001001582;005801001;торговля +Лунная Магистраль ООО;ООО;0001001590;005901001;логистика +Лунная Мануфактура АО;АО;0001001600;006001001;производство +Лунный Приём ООО;ООО;0001001617;006101001;услуги +Лунный Мост ООО;ООО;0001001624;006201001;строительство +Янтарная Нива ООО;ООО;0001001631;006301001;агро +Янтарный Рынок ООО;ООО;0001001649;006401001;торговля +Янтарный Путь АО;АО;0001001656;006501001;логистика +Янтарный Завод ООО;ООО;0001001663;006601001;производство +Янтарный Сервис ООО;ООО;0001001670;006701001;услуги +Янтарный Фундамент ООО;ООО;0001001688;006801001;строительство +Серебряная Нива ООО;ООО;0001001695;006901001;агро +Серебряный Рынок ПАО;ПАО;0001001705;007001001;торговля +Серебряный Путь ООО;ООО;0001001712;007101001;логистика +Серебряный Завод ООО;ООО;0001001720;007201001;производство +Серебряный Сервис ООО;ООО;0001001737;007301001;услуги +Серебряный Фундамент ООО;ООО;0001001744;007401001;строительство +Северная Нива АО;АО;0001001751;007501001;агро +Северный Рынок ООО;ООО;0001001769;007601001;торговля +Северный Путь ООО;ООО;0001001776;007701001;логистика +Северный Завод ООО;ООО;0001001783;007801001;производство +Северный Сервис ООО;ООО;0001001790;007901001;услуги +Северный Фундамент АО;АО;0001001800;008001001;строительство +Изумрудный Колос ООО;ООО;0001001818;008101001;агро +Изумрудный Склад ООО;ООО;0001001825;008201001;торговля +Изумрудный Маршрут ООО;ООО;0001001832;008301001;логистика +Изумрудный Цех ООО;ООО;0001001840;008401001;производство +Изумрудное Бюро АО;АО;0001001857;008501001;услуги +Изумрудный Кирпич ООО;ООО;0001001864;008601001;строительство +Заревный Колос ПАО;ПАО;0001001871;008701001;агро +Заревный Склад ООО;ООО;0001001889;008801001;торговля +Заревный Маршрут ООО;ООО;0001001896;008901001;логистика +Заревный Цех АО;АО;0001001906;009001001;производство +Заревное Бюро ООО;ООО;0001001913;009101001;услуги +Заревный Кирпич ООО;ООО;0001001920;009201001;строительство +Лазурный Урожай ООО;ООО;0001001938;009301001;агро +Лазурная Лавка ООО;ООО;0001001945;009401001;торговля +Лазурный Фургон АО;АО;0001001952;009501001;логистика +Лазурный Станок ООО;ООО;0001001960;009601001;производство +Лазурный Центр ООО;ООО;0001001977;009701001;услуги +Лазурный Монолит ООО;ООО;0001001984;009801001;строительство +Рубиновый Урожай ООО;ООО;0001001991;009901001;агро +Рубиновая Лавка АО;АО;0001002000;000001001;торговля +Рубиновый Фургон ООО;ООО;0001002018;000101001;логистика +Рубиновый Станок ООО;ООО;0001002025;000201001;производство +Рубиновый Центр ООО;ООО;0001002032;000301001;услуги +Рубиновый Монолит ПАО;ПАО;0001002040;000401001;строительство +Лунный Урожай АО;АО;0001002057;000501001;агро +Лунная Лавка ООО;ООО;0001002064;000601001;торговля +Лунный Фургон ООО;ООО;0001002071;000701001;логистика +Лунный Станок ООО;ООО;0001002089;000801001;производство +Лунный Центр ООО;ООО;0001002096;000901001;услуги +Лунный Монолит АО;АО;0001002106;001001001;строительство +Янтарная Пашня ООО;ООО;0001002113;001101001;агро +Янтарная Ярмарка ООО;ООО;0001002120;001201001;торговля +Янтарная Трасса ООО;ООО;0001002138;001301001;логистика +Янтарная Мельница ООО;ООО;0001002145;001401001;производство +Янтарный Консалт АО;АО;0001002152;001501001;услуги +Янтарный Каркас ООО;ООО;0001002160;001601001;строительство +Серебряная Пашня ООО;ООО;0001002177;001701001;агро +Серебряная Ярмарка ООО;ООО;0001002184;001801001;торговля +Серебряная Трасса ООО;ООО;0001002191;001901001;логистика +Серебряная Мельница АО;АО;0001002201;002001001;производство +Серебряный Консалт ПАО;ПАО;0001002219;002101001;услуги +Серебряный Каркас ООО;ООО;0001002226;002201001;строительство +Северная Пашня ООО;ООО;0001002233;002301001;агро +Северная Ярмарка ООО;ООО;0001002240;002401001;торговля +Северная Трасса АО;АО;0001002258;002501001;логистика +Северная Мельница ООО;ООО;0001002265;002601001;производство +Северный Консалт ООО;ООО;0001002272;002701001;услуги +Северный Каркас ООО;ООО;0001002280;002801001;строительство +Изумрудное Зерно ООО;ООО;0001002297;002901001;агро +Изумрудный Базар АО;АО;0001002307;003001001;торговля +Изумрудный Обоз ООО;ООО;0001002314;003101001;логистика +Изумрудная Пекарня ООО;ООО;0001002321;003201001;производство +Изумрудный Профи ООО;ООО;0001002339;003301001;услуги +Изумрудный Проект ООО;ООО;0001002346;003401001;строительство +Заревное Зерно АО;АО;0001002353;003501001;агро +Заревный Базар ООО;ООО;0001002360;003601001;торговля +Заревный Обоз ООО;ООО;0001002378;003701001;логистика +Заревная Пекарня ПАО;ПАО;0001002385;003801001;производство +Заревный Профи ООО;ООО;0001002392;003901001;услуги +Заревный Проект АО;АО;0001002402;004001001;строительство +Лазурный Луг ООО;ООО;0001002410;004101001;агро +Лазурный Торг ООО;ООО;0001002427;004201001;торговля +Лазурный Тягач ООО;ООО;0001002434;004301001;логистика +Лазурная Кузница ООО;ООО;0001002441;004401001;производство +Лазурный Партнёр АО;АО;0001002459;004501001;услуги +Лазурная Стройка ООО;ООО;0001002466;004601001;строительство +Рубиновый Луг ООО;ООО;0001002473;004701001;агро +Рубиновый Торг ООО;ООО;0001002480;004801001;торговля +Рубиновый Тягач ООО;ООО;0001002498;004901001;логистика +Рубиновая Кузница АО;АО;0001002508;005001001;производство +Рубиновый Партнёр ООО;ООО;0001002515;005101001;услуги +Рубиновая Стройка ООО;ООО;0001002522;005201001;строительство +Лунный Луг ООО;ООО;0001002530;005301001;агро +Лунный Торг ООО;ООО;0001002547;005401001;торговля +Лунный Тягач ПАО;ПАО;0001002554;005501001;логистика +Лунная Кузница ООО;ООО;0001002561;005601001;производство +Лунный Партнёр ООО;ООО;0001002579;005701001;услуги +Лунная Стройка ООО;ООО;0001002586;005801001;строительство +Янтарный Сад ООО;ООО;0001002593;005901001;агро +Янтарный Магазин АО;АО;0001002603;006001001;торговля +Янтарный Рейс ООО;ООО;0001002610;006101001;логистика +Янтарная Фабрика ООО;ООО;0001002628;006201001;производство +Янтарный Совет ООО;ООО;0001002635;006301001;услуги +Янтарный Бастион ООО;ООО;0001002642;006401001;строительство +Серебряный Сад АО;АО;0001002650;006501001;агро +Серебряный Магазин ООО;ООО;0001002667;006601001;торговля +Серебряный Рейс ООО;ООО;0001002674;006701001;логистика +Серебряная Фабрика ООО;ООО;0001002681;006801001;производство +Серебряный Совет ООО;ООО;0001002699;006901001;услуги +Серебряный Бастион АО;АО;0001002709;007001001;строительство +Северный Сад ООО;ООО;0001002716;007101001;агро +Северный Магазин ПАО;ПАО;0001002723;007201001;торговля +Северный Рейс ООО;ООО;0001002730;007301001;логистика +Северная Фабрика ООО;ООО;0001002748;007401001;производство +Северный Совет АО;АО;0001002755;007501001;услуги +Северный Бастион ООО;ООО;0001002762;007601001;строительство +Изумрудная Борозда ООО;ООО;0001002770;007701001;агро +Изумрудный Прилавок ООО;ООО;0001002787;007801001;торговля +Изумрудный Перевал ООО;ООО;0001002794;007901001;логистика +Изумрудный Горн АО;АО;0001002804;008001001;производство +Изумрудная Контора ООО;ООО;0001002811;008101001;услуги +Изумрудный Зодчий ООО;ООО;0001002829;008201001;строительство +Заревная Борозда ООО;ООО;0001002836;008301001;агро +Заревный Прилавок ООО;ООО;0001002843;008401001;торговля +Заревный Перевал АО;АО;0001002850;008501001;логистика +Заревный Горн ООО;ООО;0001002868;008601001;производство +Заревная Контора ООО;ООО;0001002875;008701001;услуги +Заревный Зодчий ООО;ООО;0001002882;008801001;строительство +Лазурное Поле ПАО;ПАО;0001002890;008901001;агро +Лазурный Пассаж АО;АО;0001002900;009001001;торговля +Лазурный Караван ООО;ООО;0001002917;009101001;логистика +Лазурная Верфь ООО;ООО;0001002924;009201001;производство +Лазурная Мастерская ООО;ООО;0001002931;009301001;услуги +Лазурная Арка ООО;ООО;0001002949;009401001;строительство +Рубиновое Поле АО;АО;0001002956;009501001;агро +Рубиновый Пассаж ООО;ООО;0001002963;009601001;торговля +Рубиновый Караван ООО;ООО;0001002970;009701001;логистика +Рубиновая Верфь ООО;ООО;0001002988;009801001;производство +Рубиновая Мастерская ООО;ООО;0001002995;009901001;услуги +Рубиновая Арка АО;АО;0001003004;000001001;строительство +Лунное Поле ООО;ООО;0001003011;000101001;агро +Лунный Пассаж ООО;ООО;0001003029;000201001;торговля +Лунный Караван ООО;ООО;0001003036;000301001;логистика +Лунная Верфь ООО;ООО;0001003043;000401001;производство +Лунная Мастерская АО;АО;0001003050;000501001;услуги +Лунная Арка ПАО;ПАО;0001003068;000601001;строительство +Янтарный Амбар ООО;ООО;0001003075;000701001;агро +Янтарный Товар ООО;ООО;0001003082;000801001;торговля +Янтарная Магистраль ООО;ООО;0001003090;000901001;логистика +Янтарная Мануфактура АО;АО;0001003100;001001001;производство +Янтарный Приём ООО;ООО;0001003117;001101001;услуги +Янтарный Мост ООО;ООО;0001003124;001201001;строительство +Серебряный Амбар ООО;ООО;0001003131;001301001;агро +Серебряный Товар ООО;ООО;0001003149;001401001;торговля +Серебряная Магистраль АО;АО;0001003156;001501001;логистика +Серебряная Мануфактура ООО;ООО;0001003163;001601001;производство +Серебряный Приём ООО;ООО;0001003170;001701001;услуги +Серебряный Мост ООО;ООО;0001003188;001801001;строительство +Северный Амбар ООО;ООО;0001003195;001901001;агро +Северный Товар АО;АО;0001003205;002001001;торговля +Северная Магистраль ООО;ООО;0001003212;002101001;логистика +Северная Мануфактура ООО;ООО;0001003220;002201001;производство +Северный Приём ПАО;ПАО;0001003237;002301001;услуги +Северный Мост ООО;ООО;0001003244;002401001;строительство +Изумрудная Нива АО;АО;0001003251;002501001;агро +Изумрудный Рынок ООО;ООО;0001003269;002601001;торговля +Изумрудный Путь ООО;ООО;0001003276;002701001;логистика +Изумрудный Завод ООО;ООО;0001003283;002801001;производство +Изумрудный Сервис ООО;ООО;0001003290;002901001;услуги +Изумрудный Фундамент АО;АО;0001003300;003001001;строительство +Авдонина Е.И. ИП;ИП;000010033189;;услуги +Бражников А.П. ИП;ИП;000010033252;;торговля +Ветрова Н.С. ИП;ИП;000010033326;;логистика +Голубев В.Д. ИП;ИП;000010033407;;агро +Дроздова О.А. ИП;ИП;000010033573;;строительство +Ермаков М.В. ИП;ИП;000010033647;;производство +Жарова И.Н. ИП;ИП;000010033710;;услуги +Зуев С.К. ИП;ИП;000010033894;;торговля +Игнатова Т.Р. ИП;ИП;000010033968;;логистика +Копылов Д.Л. ИП;ИП;000010034062;;агро +Лаврова Е.И. ИП;ИП;000010034136;;строительство +Мартынов А.П. ИП;ИП;000010034200;;производство +Норина Н.С. ИП;ИП;000010034383;;услуги +Овчаров В.Д. ИП;ИП;000010034457;;торговля +Пименова О.А. ИП;ИП;000010034520;;логистика +Рябов М.В. ИП;ИП;000010034601;;агро +Сорокина И.Н. ИП;ИП;000010034778;;строительство +Тарасов С.К. ИП;ИП;000010034841;;производство +Устинова Т.Р. ИП;ИП;000010034915;;услуги +Фролов Д.Л. ИП;ИП;000010035010;;торговля +Хромова Е.И. ИП;ИП;000010035193;;логистика +Цветков А.П. ИП;ИП;000010035267;;агро +Чернова Н.С. ИП;ИП;000010035330;;строительство +Шаров В.Д. ИП;ИП;000010035404;;производство +Щукина О.А. ИП;ИП;000010035588;;услуги +Юдин М.В. ИП;ИП;000010035651;;торговля +Яковлева И.Н. ИП;ИП;000010035725;;логистика +Баранов С.К. ИП;ИП;000010035806;;агро +Волкова Т.Р. ИП;ИП;000010035972;;строительство +Гусев Д.Л. ИП;ИП;000010036077;;производство +Денисова Е.И. ИП;ИП;000010036140;;услуги +Елисеев А.П. ИП;ИП;000010036214;;торговля +Зайцева Н.С. ИП;ИП;000010036398;;логистика +Исаев В.Д. ИП;ИП;000010036461;;агро +Калинина О.А. ИП;ИП;000010036535;;строительство +Леонов М.В. ИП;ИП;000010036609;;производство +Маслова И.Н. ИП;ИП;000010036782;;услуги +Назаров С.К. ИП;ИП;000010036856;;торговля +Орлова Т.Р. ИП;ИП;000010036920;;логистика +Панов Д.Л. ИП;ИП;000010037024;;агро +Лазурный Банк АО;АО;0001003710;007101001;банк +Янтарный Банк ПАО;ПАО;0001003727;007201001;банк +Изумрудный Банк АО;АО;0001003734;007301001;банк +Рубиновый Банк ПАО;ПАО;0001003741;007401001;банк +Серебряный Банк АО;АО;0001003759;007501001;банк +Заревный Банк ПАО;ПАО;0001003766;007601001;банк +Лунный Банк АО;АО;0001003773;007701001;банк +Северный Банк ПАО;ПАО;0001003780;007801001;банк +Лазурный Кредитный Банк АО;АО;0001003798;007901001;банк +Янтарный Кредитный Банк ПАО;ПАО;0001003808;008001001;банк +Изумрудный Кредитный Банк АО;АО;0001003815;008101001;банк +Рубиновый Кредитный Банк ПАО;ПАО;0001003822;008201001;банк +Серебряный Кредитный Банк АО;АО;0001003830;008301001;банк +Заревный Кредитный Банк ПАО;ПАО;0001003847;008401001;банк +Лунный Кредитный Банк АО;АО;0001003854;008501001;банк +Северный Кредитный Банк ПАО;ПАО;0001003861;008601001;банк +Лазурный Деловой Банк АО;АО;0001003879;008701001;банк +Янтарный Деловой Банк ПАО;ПАО;0001003886;008801001;банк +Изумрудный Деловой Банк АО;АО;0001003893;008901001;банк +Рубиновый Деловой Банк ПАО;ПАО;0001003903;009001001;банк +Серебряный Деловой Банк АО;АО;0001003910;009101001;банк +Заревный Деловой Банк ПАО;ПАО;0001003928;009201001;банк +Лунный Деловой Банк АО;АО;0001003935;009301001;банк +Северный Деловой Банк ПАО;ПАО;0001003942;009401001;банк +Лазурный Земельный Банк АО;АО;0001003950;009501001;банк +Янтарный Земельный Банк ПАО;ПАО;0001003967;009601001;банк +Изумрудный Земельный Банк АО;АО;0001003974;009701001;банк +Рубиновый Земельный Банк ПАО;ПАО;0001003981;009801001;банк +Серебряный Земельный Банк АО;АО;0001003999;009901001;банк +Заревный Земельный Банк ПАО;ПАО;0001004008;000001001;банк diff --git a/synthgen/ecp.py b/synthgen/ecp.py new file mode 100644 index 0000000..bf1bd2b --- /dev/null +++ b/synthgen/ecp.py @@ -0,0 +1,29 @@ +"""Источник: synthetic-group/gen/ecp.py (#35). Выгрузка ЕЦП (спека §6.3): состав ГК, все компании — «Входит в ГСК». + +Формат сверен с импортом ядра (`arm-core/api/src/domain/directory_import.py`): обязательные колонки ИНН, +iD ГК, Группа компаний, Организация, Тип; необязательные Базовый РФ ГК в CRM, ГСК, ГСЗ, КПП. +""" + +from pathlib import Path + +import openpyxl + +from synthgen.group import Group +from synthgen.xlsx import save_deterministic + +HEADER = ["ИНН", "КПП", "iD ГК", "Базовый РФ ГК в CRM", "Группа компаний", "Организация", "ГСК", "ГСЗ", "Тип"] +BASE_RF = "Ростовский региональный филиал" # как в #35 + + +def write_ecp(group: Group, path: Path) -> None: + wb = openpyxl.Workbook() + ws = wb.active + assert ws is not None + ws.title = "Выписки" + ws.append(HEADER) + for m in group.members: + ws.append([m.inn, m.kpp, group.gk_id, BASE_RF, group.name, m.name, "Входит в ГСК", None, "ЮЛ"]) + for row in ws.iter_rows(min_row=2, max_col=2): + for cell in row: + cell.number_format = "@" # ИНН и КПП — текстом + save_deterministic(wb, path) diff --git a/synthgen/group.py b/synthgen/group.py new file mode 100644 index 0000000..bdfce99 --- /dev/null +++ b/synthgen/group.py @@ -0,0 +1,118 @@ +"""Состав группы компаний (спека §4.2): члены из словаря, ИНН по префиксу, доли, компании без ИНН в ОСВ.""" + +from dataclasses import dataclass +from decimal import Decimal + +from synthgen import inn +from synthgen.config import Config +from synthgen.dictionary import Org +from synthgen.rng import rng_for, stable_hash + +_HASH_RANGE = 90000 +_CENT = Decimal("0.01") + + +@dataclass(frozen=True) +class Member: + code: str # C01, C02, … + org: Org # запись словаря, давшая корень имени + name: str # АО " <корень>" + name_1c: str # <корень> АО — как 1С пишет контрагента + inn: str + kpp: str + is_head: bool + head_share: Decimal | None # доля головной в дочерней; у головной None + minority: Org | None # миноритарий дочерней (внешний держатель остальной доли) + + +@dataclass(frozen=True) +class Group: + prefix: str + name: str # ГК + gk_id: str # -000001 + members: list[Member] + no_inn: frozenset[str] # коды компаний, чья ОСВ записана без ИНН + variant_name: dict[str, str] # код ОСВ-компании без ИНН → код члена группы, записанного другим написанием + externals: list[Org] # словарь без банков и без выбранных в группу + banks: list[Org] + + +def member_inn(prefix: str, i: int) -> str: + """ИНН i-й компании группы (i от 1, < 100): номер = 1000000 + 100 × h(prefix) + i.""" + return inn.make_inn10(1000000 + 100 * stable_hash(prefix, _HASH_RANGE) + i) + + +def member_kpp(i: int) -> str: + return f"00{i:02d}01001" + + +def variant_spelling(member: Member) -> str: + """Вариантное написание члена группы в ОСВ без ИНН: корень, префикс, форма без кавычек (решение 17).""" + root = member.org.root + prefix = member.name_1c[: -(len(root) + len(member.org.form) + 2)] + return f"{root} {prefix} {member.org.form}" + + +def build_group(cfg: Config, orgs: list[Org]) -> Group: + rng = rng_for(cfg.seed, "group", cfg.prefix) + candidates = [o for o in orgs if o.form in ("ООО", "АО") and o.kind != "банк"] + heads = [o for o in candidates if o.form == "АО"] + head_org = rng.choice(heads) + rest = [o for o in candidates if o is not head_org] + chosen = [head_org, *rng.sample(rest, cfg.group_size - 1)] + + children = list(range(1, cfg.group_size)) + with_minority = set(rng.sample(children, min(len(children), rng.randint(1, 2)))) if children else set() + chosen_set = {o.inn for o in chosen} + pool = [o for o in orgs if o.kind != "банк" and o.form != "ИП" and o.inn not in chosen_set] + + members: list[Member] = [] + for idx, org in enumerate(chosen): + i = idx + 1 + share: Decimal | None = None + minority: Org | None = None + if idx > 0: + if idx in with_minority: + share = (Decimal(rng.randint(51, 99)) / 100).quantize(_CENT) + minority = rng.choice(pool) + else: + share = Decimal("1.00") + members.append( + Member( + code=f"C{i:02d}", + org=org, + name=f'{org.form} "{cfg.prefix} {org.root}"', + name_1c=f"{cfg.prefix} {org.root} {org.form}", + inn=member_inn(cfg.prefix, i), + kpp=member_kpp(i), + is_head=idx == 0, + head_share=share, + minority=minority, + ) + ) + + codes = [m.code for m in members] + child_codes = codes[1:] + n = cfg.no_inn_companies + picked = rng.sample(child_codes, min(n, len(child_codes))) + if n > len(child_codes): + picked.append(codes[0]) + no_inn = frozenset(picked) + + variant_name: dict[str, str] = {} + for code in sorted(no_inn): + others = [c for c in codes if c != code] + variant_name[code] = rng.choice(others) + + externals = [o for o in orgs if o.kind != "банк" and o.inn not in chosen_set] + banks = [o for o in orgs if o.kind == "банк"] + return Group( + prefix=cfg.prefix, + name=f"ГК {cfg.prefix}", + gk_id=f"{cfg.prefix}-000001", + members=members, + no_inn=no_inn, + variant_name=variant_name, + externals=externals, + banks=banks, + ) diff --git a/synthgen/tests/test_dictionary.py b/synthgen/tests/test_dictionary.py new file mode 100644 index 0000000..853a120 --- /dev/null +++ b/synthgen/tests/test_dictionary.py @@ -0,0 +1,72 @@ +import re +from pathlib import Path + +import openpyxl +import pytest + +from synthgen import dictionary, inn +from synthgen.dictionary import build + +REAL_ECP = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/directories/ecp-export-russkiy-biskvit.xlsx") +REAL_OSV_DIR = Path("/Users/andrey.epifancev/projects/awp-ca/fixtures/osv/rb/2024-Q1") +_FORMS = re.compile(r"\b(ООО|АО|ПАО|ЗАО|ОАО|ИП)\b", re.IGNORECASE) + + +def _norm(text: str) -> str: + text = _FORMS.sub(" ", text) + return re.sub(r"[^0-9a-zа-яё]+", " ", text.lower().replace("ё", "е")).strip() + + +def test_dictionary_size_and_kinds(): + orgs = dictionary.load() + assert len(orgs) == 400 + assert sum(o.kind == "банк" for o in orgs) == 30 + assert 30 <= sum(o.form == "ИП" for o in orgs) <= 50 + names = [o.name.lower() for o in orgs] + assert len(set(names)) == len(names) + + +def test_dictionary_inn(): + for o in dictionary.load(): + assert inn.is_valid(o.inn) + if o.form == "ИП": + assert len(o.inn) == 12 and o.kpp == "" + number = int(o.inn[2:-2]) + else: + assert len(o.inn) == 10 + assert re.fullmatch(r"\d{9}", o.kpp) + number = int(o.inn[2:-1]) + assert 100001 <= number <= 109999 + + +def _real_names() -> set[str]: + names: set[str] = set() + wb = openpyxl.load_workbook(REAL_ECP, read_only=True) + for ws in wb.worksheets: + rows = ws.iter_rows(values_only=True) + header = next(rows, None) + if not header or "Организация" not in header: + continue + col = list(header).index("Организация") + names.update(_norm(str(r[col])) for r in rows if r[col]) + for path in sorted(REAL_OSV_DIR.glob("*.xlsx")): + wb = openpyxl.load_workbook(path, read_only=True) + ws = wb.worksheets[0] + names.update(_norm(str(r[0])) for r in ws.iter_rows(max_col=1, values_only=True) if r and r[0]) + names.discard("") + return names + + +def test_dictionary_not_real(): + if not REAL_ECP.exists() or not REAL_OSV_DIR.is_dir(): + pytest.skip("нет реальных фикстур") + real = _real_names() + assert len(real) > 50 + clash = [o.name for o in dictionary.load() if _norm(o.name) in real] + assert clash == [] + + +def test_build_is_reproducible(tmp_path): + out = tmp_path / "companies.csv" + build.write_csv(out) + assert out.read_bytes() == dictionary.CSV_PATH.read_bytes() diff --git a/synthgen/tests/test_ecp.py b/synthgen/tests/test_ecp.py new file mode 100644 index 0000000..6919969 --- /dev/null +++ b/synthgen/tests/test_ecp.py @@ -0,0 +1,24 @@ +import openpyxl + +from synthgen import dictionary +from synthgen.config import Config +from synthgen.ecp import write_ecp +from synthgen.group import build_group + + +def test_ecp_format(tmp_path): + g = build_group(Config(group_size=6, prefix="DEMO2"), dictionary.load()) + path = tmp_path / "ecp-DEMO2.xlsx" + write_ecp(g, path) + wb = openpyxl.load_workbook(path) + assert wb.sheetnames == ["Выписки"] + ws = wb["Выписки"] + rows = list(ws.iter_rows(values_only=True)) + assert list(rows[0]) == ["ИНН", "КПП", "iD ГК", "Базовый РФ ГК в CRM", "Группа компаний", "Организация", "ГСК", "ГСЗ", "Тип"] + assert len(rows) - 1 == 6 + for row, member in zip(rows[1:], g.members): + assert row[0] == member.inn and row[1] == member.kpp and row[5] == member.name + assert row[2] == "DEMO2-000001" and row[4] == "ГК DEMO2" + assert row[6] == "Входит в ГСК" and row[8] == "ЮЛ" + for r in ws.iter_rows(min_row=2, max_col=2): + assert all(c.number_format == "@" and isinstance(c.value, str) for c in r) diff --git a/synthgen/tests/test_group.py b/synthgen/tests/test_group.py new file mode 100644 index 0000000..a9a6692 --- /dev/null +++ b/synthgen/tests/test_group.py @@ -0,0 +1,105 @@ +from dataclasses import replace +from decimal import Decimal + +import pytest + +from synthgen import dictionary, inn +from synthgen.config import Config +from synthgen.group import build_group, member_inn, variant_spelling +from synthgen.ecp import write_ecp +from synthgen.rng import stable_hash + + +@pytest.fixture(scope="module") +def orgs(): + return dictionary.load() + + +def test_group_size_and_head(orgs): + g = build_group(Config(group_size=5), orgs) + assert len(g.members) == 5 + assert [m.code for m in g.members] == ["C01", "C02", "C03", "C04", "C05"] + assert g.members[0].is_head and g.members[0].org.form == "АО" and g.members[0].head_share is None + for m in g.members: + assert m.org.form in ("ООО", "АО") and m.org.kind != "банк" + assert not m.is_head or m is g.members[0] + assert g.name == "ГК SYN" and g.gk_id == "SYN-000001" + for m in g.members: + assert m.name == f'{m.org.form} "SYN {m.org.root}"' + assert m.name_1c == f"SYN {m.org.root} {m.org.form}" + assert len(m.kpp) == 9 and inn.is_valid(m.inn) + + +def test_member_inn_range(): + value = member_inn("SYN", 3) + assert inn.is_valid(value) + assert int(value[2:-1]) == 1000000 + 100 * stable_hash("SYN", 90000) + 3 + + +def test_prefixes_do_not_collide(orgs): + a = {m.inn for m in build_group(Config(prefix="SYN"), orgs).members} + b = {m.inn for m in build_group(Config(prefix="DEMO2"), orgs).members} + assert not a & b + assert not (a | b) & {o.inn for o in orgs} + + +def test_externals_exclude_group_and_banks(orgs): + g = build_group(Config(group_size=8), orgs) + chosen = {m.org.inn for m in g.members} + assert all(o.kind != "банк" for o in g.externals) + assert not chosen & {o.inn for o in g.externals} + assert len(g.externals) == sum(o.kind != "банк" for o in orgs) - 8 + assert len(g.banks) == 30 and all(o.kind == "банк" for o in g.banks) + + +def test_no_inn_selection(orgs): + g = build_group(Config(group_size=5, no_inn_companies=2), orgs) + assert len(g.no_inn) == 2 and "C01" not in g.no_inn + g = build_group(Config(group_size=5, no_inn_companies=4), orgs) + assert len(g.no_inn) == 4 and "C01" not in g.no_inn + g = build_group(Config(group_size=5, no_inn_companies=5), orgs) + assert g.no_inn == frozenset(m.code for m in g.members) + assert build_group(Config(no_inn_companies=0), orgs).no_inn == frozenset() + + +@pytest.mark.parametrize("size,n", [(2, 2), (5, 2), (5, 5), (10, 3)]) +def test_variant_name(orgs, size, n): + g = build_group(Config(group_size=size, no_inn_companies=n), orgs) + by_code = {m.code: m for m in g.members} + assert set(g.variant_name) == set(g.no_inn) + for code, other in g.variant_name.items(): + assert other != code and other in by_code + m = by_code[other] + variant = variant_spelling(m) + assert variant not in (m.name_1c, m.name) + assert variant != by_code[code].name_1c + + +def test_shares(orgs): + for size in (2, 5, 12): + g = build_group(Config(group_size=size), orgs) + children = g.members[1:] + for m in children: + assert Decimal("0.51") <= m.head_share <= Decimal("1.00") + with_min = [m for m in children if m.minority is not None] + assert len(with_min) <= 2 + if size > 2: + assert 1 <= len(with_min) + for m in with_min: + assert m.head_share < 1 + for m in children: + if m.minority is None: + assert m.head_share == Decimal("1.00") + + +def test_prefix_cyrillic_and_digits(orgs, tmp_path): + for prefix in ("ДЕМО", "2GK"): + g = build_group(Config(prefix=prefix), orgs) + assert g.gk_id == f"{prefix}-000001" + write_ecp(g, tmp_path / f"ecp-{prefix}.xlsx") + + +def test_group_deterministic(orgs): + cfg = Config(group_size=7, no_inn_companies=2, seed=5) + assert build_group(cfg, orgs) == build_group(cfg, orgs) + assert build_group(cfg, orgs) != build_group(replace(cfg, seed=6), orgs)