diff --git a/.gitignore b/.gitignore index 37a256a..e73a8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ ~$* *.tmp .superpowers/ +out/ diff --git a/docs/superpowers/specs/2026-11-12-synthgen-design.md b/docs/superpowers/specs/2026-11-12-synthgen-design.md index d11dc7d..606fd01 100644 --- a/docs/superpowers/specs/2026-11-12-synthgen-design.md +++ b/docs/superpowers/specs/2026-11-12-synthgen-design.md @@ -276,3 +276,5 @@ README прогона, точные субсчета внутри перечис | 8 | 12.11.2026 | Значения по умолчанию §3 (5 компаний, `SYN`, 4 периода с 2025-Q1, 60 контрагентов, 1 без ИНН, доля 0.1) | разумный стартовый объём: «есть что смотреть», прогон быстрый | никакой — меняется настройкой | | 13 | 12.11.2026 | Тесты жизненного цикла расхождений ВГО проверяют снятие 31 января, только если январь следующего года внутри периодов книги; иначе — только удержание до конца года (R-1) | книга кончается последним периодом, января после него нет | непроверенное снятие расхождения в хвосте набора | | 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 одноразово — перегенерировать образцы | diff --git a/synthgen/__init__.py b/synthgen/__init__.py new file mode 100644 index 0000000..f6c859f --- /dev/null +++ b/synthgen/__init__.py @@ -0,0 +1 @@ +"""Генератор синтетического набора АРМ КА (#38): ГК, ОСВ, ББ/ОФР, ВГО, ЕЦП — по спеке docs/superpowers/specs/2026-11-12-synthgen-design.md.""" diff --git a/synthgen/__main__.py b/synthgen/__main__.py new file mode 100644 index 0000000..23bcf3d --- /dev/null +++ b/synthgen/__main__.py @@ -0,0 +1,40 @@ +"""Точка входа: `python -m synthgen --config synthgen/examples/default.yaml --set group_size=12 --out DIR`. + +Пока печатает итоговые настройки (спека §3); генерация набора добавится в следующих задачах. +""" + +import argparse +import sys +from dataclasses import replace +from pathlib import Path + +from synthgen.config import ConfigError, dump_config, load_config + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="synthgen", description="Генератор синтетического набора АРМ КА.") + parser.add_argument("--config", type=Path, default=None, help="YAML с настройками") + parser.add_argument( + "--set", dest="overrides", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", + help="переопределение настройки (можно повторять)", + ) + parser.add_argument("--out", type=Path, default=None, help="каталог результата") + parser.add_argument("--force", action="store_true", help="перезаписать существующий каталог результата") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + cfg = load_config(args.config, args.overrides) + except ConfigError as exc: + print(f"ошибка конфигурации: {exc}", file=sys.stderr) + return 2 + if args.out is not None: + cfg = replace(cfg, out=args.out) + sys.stdout.write(dump_config(cfg)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/synthgen/chart.py b/synthgen/chart.py new file mode 100644 index 0000000..103c5a4 --- /dev/null +++ b/synthgen/chart.py @@ -0,0 +1,79 @@ +"""Источник: synthetic-group/gen/chart.py (#35). Используемые субсчета плана счетов (коды — из seed arm-core chart_of_accounts.csv).""" + +ACCOUNTS: dict[str, str] = { + "000": "Вспомогательный счет", + "01": "Основные средства", + "02": "Амортизация основных средств", + "03": "Доходные вложения в материальные ценности", + "08": "Вложения во внеоборотные активы", + "10": "Материалы", + "19": "НДС по приобретенным ценностям", + "20": "Основное производство", + "26": "Общехозяйственные расходы", + "41": "Товары", + "43": "Готовая продукция", + "44": "Расходы на продажу", + "50": "Касса", + "51": "Расчетные счета", + "52": "Валютные счета", + "55": "Специальные счета в банках", + "58.01": "Паи и акции", + "58.03": "Предоставленные займы", + "59": "Резервы под обесценение финансовых вложений", + "60.01": "Расчеты с поставщиками и подрядчиками", + "60.02": "Расчеты по авансам выданным", + "62.01": "Расчеты с покупателями и заказчиками", + "62.02": "Расчеты по авансам полученным", + "66.01": "Краткосрочные кредиты", + "66.02": "Проценты по краткосрочным кредитам", + "66.03": "Краткосрочные займы", + "66.04": "Проценты по краткосрочным займам", + "67.03": "Долгосрочные займы", + "67.04": "Проценты по долгосрочным займам", + "68": "Расчеты по налогам и сборам", + "68.04": "Налог на прибыль", + "69": "Расчеты по социальному страхованию и обеспечению", + "70": "Расчеты с персоналом по оплате труда", + "71": "Расчеты с подотчетными лицами", + "73": "Расчеты с персоналом по прочим операциям", + "75.02": "Расчеты по выплате доходов", + "76.03": "Расчеты по причитающимся дивидендам и другим доходам", + "76.05": "Расчеты с прочими поставщиками и подрядчиками", + "76.07.1": "Арендные обязательства", + "76.07.9": "НДС по арендным обязательствам", + "76.09": "Прочие расчеты с разными дебиторами и кредиторами", + "76.АВ": "НДС по авансам и предоплатам", + "80": "Уставный капитал", + "80.09": "Прочий капитал", + "82": "Резервный капитал", + "83": "Добавочный капитал", + "84": "Нераспределенная прибыль (непокрытый убыток)", + "86": "Целевое финансирование", + "90.01": "Выручка", + "90.02": "Себестоимость продаж", + "90.03": "Налог на добавленную стоимость", + "90.04": "Акцизы", + "90.05": "Экспортные пошлины", + # 90.06 в seed отсутствует + "90.07": "Расходы на продажу", + "90.08": "Управленческие расходы", + "90.09": "Прибыль / убыток от продаж", + "91.01": "Прочие доходы", + "91.02": "Прочие расходы", + "91.09": "Сальдо прочих доходов и расходов", + "96": "Резервы предстоящих расходов", + "97": "Расходы будущих периодов", + "99": "Прибыли и убытки", + "001": "Арендованные основные средства", + "008": "Обеспечения обязательств и платежей полученные", + "009": "Обеспечения обязательств и платежей выданные", + "011": "Основные средства, сданные в аренду", +} + + +def is_off_balance(code: str) -> bool: + return code.isdigit() and len(code) == 3 and "001" <= code <= "011" + + +def parent(code: str) -> str: + return code.rsplit(".", 1)[0] if "." in code else code diff --git a/synthgen/config.py b/synthgen/config.py new file mode 100644 index 0000000..6d79c53 --- /dev/null +++ b/synthgen/config.py @@ -0,0 +1,151 @@ +"""Настройки генератора (спека §3): YAML-файл + переопределения `--set ключ=значение`. + +Неизвестный ключ или значение вне диапазона — ConfigError до начала генерации. +""" + +import re +from dataclasses import dataclass, fields +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +import yaml + + +class ConfigError(ValueError): + """Ошибка настроек: неизвестный ключ, неверный тип или значение вне диапазона.""" + + +@dataclass(frozen=True) +class Config: + group_size: int = 5 + prefix: str = "SYN" + periods: int = 4 + start: str = "2025-Q1" + osv_counterparties_mean: int = 60 + no_inn_companies: int = 1 + vgo_mismatch_share: Decimal = Decimal("0.1") + seed: int = 1 + out: Path | None = None + + +_PREFIX_RE = re.compile(r"[0-9A-Za-zА-Яа-яЁё]{2,12}") +_START_RE = re.compile(r"(\d{4})-Q([1-4])") + + +def _as_int(name: str, value: Any) -> int: + if isinstance(value, bool): + raise ConfigError(f"{name}: ожидается целое число, получено {value!r}") + if isinstance(value, int): + return value + if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()): + return int(value.strip()) + raise ConfigError(f"{name}: ожидается целое число, получено {value!r}") + + +def _as_str(name: str, value: Any) -> str: + if isinstance(value, bool): + raise ConfigError(f"{name}: ожидается строка, получено {value!r}") + if isinstance(value, (str, int)): + return str(value) + raise ConfigError(f"{name}: ожидается строка, получено {value!r}") + + +def _as_decimal(name: str, value: Any) -> Decimal: + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + raise ConfigError(f"{name}: ожидается число, получено {value!r}") + try: + number = Decimal(str(value).strip()) + except InvalidOperation as exc: + raise ConfigError(f"{name}: ожидается число, получено {value!r}") from exc + if not number.is_finite(): + raise ConfigError(f"{name}: ожидается конечное число, получено {value!r}") + return number + + +def _as_path(name: str, value: Any) -> Path | None: + if value is None or value == "": + return None + if isinstance(value, str): + return Path(value) + raise ConfigError(f"{name}: ожидается путь, получено {value!r}") + + +_COERCERS = { + "group_size": _as_int, + "prefix": _as_str, + "periods": _as_int, + "start": _as_str, + "osv_counterparties_mean": _as_int, + "no_inn_companies": _as_int, + "vgo_mismatch_share": _as_decimal, + "seed": _as_int, + "out": _as_path, +} + + +def _validate(cfg: Config) -> None: + if not 2 <= cfg.group_size <= 40: + raise ConfigError(f"group_size: вне диапазона 2–40, получено {cfg.group_size}") + if not _PREFIX_RE.fullmatch(cfg.prefix): + raise ConfigError(f"prefix: 2–12 букв/цифр без пробелов, получено {cfg.prefix!r}") + if not 1 <= cfg.periods <= 12: + raise ConfigError(f"periods: вне диапазона 1–12, получено {cfg.periods}") + match = _START_RE.fullmatch(cfg.start) + if match is None: + raise ConfigError(f"start: ожидается ГГГГ-Qn, получено {cfg.start!r}") + start_key = (int(match.group(1)), int(match.group(2))) + if not (2020, 1) <= start_key <= (2030, 4): + raise ConfigError(f"start: вне диапазона 2020-Q1 … 2030-Q4, получено {cfg.start!r}") + if not 0 <= cfg.osv_counterparties_mean <= 150: + raise ConfigError(f"osv_counterparties_mean: вне диапазона 0–150, получено {cfg.osv_counterparties_mean}") + if not 0 <= cfg.no_inn_companies <= cfg.group_size: + raise ConfigError( + f"no_inn_companies: вне диапазона 0–group_size ({cfg.group_size}), получено {cfg.no_inn_companies}" + ) + if not Decimal(0) <= cfg.vgo_mismatch_share <= Decimal(1): + raise ConfigError(f"vgo_mismatch_share: вне диапазона 0–1, получено {cfg.vgo_mismatch_share}") + + +def load_config(path: Path | None, overrides: list[str]) -> Config: + """Читает YAML (если задан) и накладывает переопределения `ключ=значение`.""" + known = {f.name for f in fields(Config)} + data: dict[str, Any] = {} + if path is not None: + try: + loaded = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + except OSError as exc: + raise ConfigError(f"не удалось прочитать {path}: {exc}") from exc + except yaml.YAMLError as exc: + raise ConfigError(f"некорректный YAML в {path}: {exc}") from exc + if loaded is None: + loaded = {} + if not isinstance(loaded, dict): + raise ConfigError(f"{path}: верхний уровень должен быть отображением ключ: значение") + data.update(loaded) + for item in overrides: + if "=" not in item: + raise ConfigError(f"--set: ожидается ключ=значение, получено {item!r}") + key, value = item.split("=", 1) + data[key.strip()] = value + kwargs: dict[str, Any] = {} + for key, value in data.items(): + if key not in known: + raise ConfigError(f"неизвестный ключ: {key!r}") + kwargs[key] = _COERCERS[key](key, value) + cfg = Config(**kwargs) + _validate(cfg) + return cfg + + +def dump_config(cfg: Config) -> str: + """YAML с ключами в порядке полей Config; значение `out` None → null.""" + data: dict[str, Any] = {} + for f in fields(Config): + value = getattr(cfg, f.name) + if isinstance(value, Path): + value = str(value) + elif isinstance(value, Decimal): + value = float(value) + data[f.name] = value + return yaml.safe_dump(data, sort_keys=False, allow_unicode=True, default_flow_style=False) diff --git a/synthgen/examples/default.yaml b/synthgen/examples/default.yaml new file mode 100644 index 0000000..c818075 --- /dev/null +++ b/synthgen/examples/default.yaml @@ -0,0 +1,12 @@ +# Настройки генератора синтетики по умолчанию (спека §3). +# Любой ключ переопределяется: --set ключ=значение. + +group_size: 5 # число компаний в ГК (все входят в ГСК), 2–40 +prefix: SYN # префикс ГК: 2–12 букв/цифр без пробелов; задаёт название группы и диапазон ИНН +periods: 4 # число отчётных периодов, 1–12 +start: 2025-Q1 # первый период, ГГГГ-Qn (2020-Q1 … 2030-Q4) +osv_counterparties_mean: 60 # среднее число внешних контрагентов в одной ОСВ, 0–150 +no_inn_companies: 1 # сколько компаний группы выгружают ОСВ без ИНН контрагентов, 0–group_size +vgo_mismatch_share: 0.1 # доля пар ВГО с расхождением сторон, 0–1 +seed: 1 # зерно случайности +out: null # каталог результата; null → ./out/ (или --out) diff --git a/synthgen/inn.py b/synthgen/inn.py new file mode 100644 index 0000000..c1a5ccd --- /dev/null +++ b/synthgen/inn.py @@ -0,0 +1,36 @@ +"""Источник: synthetic-group/gen/inn.py (#35). + +Вымышленные ИНН: регион `00` (не существует), верная контрольная сумма ФНС. Без случайности.""" + +_W10 = (2, 4, 10, 3, 5, 9, 4, 6, 8) +_W12_1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_W12_2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) + + +def _check(digits: list[int], weights: tuple[int, ...]) -> int: + return sum(d * w for d, w in zip(digits, weights)) % 11 % 10 + + +def make_inn10(seq: int) -> str: + """ИНН юрлица: `00` + 7 цифр номера + контрольная цифра.""" + body = [int(c) for c in f"00{seq:07d}"] + return "".join(map(str, body)) + str(_check(body, _W10)) + + +def make_inn12(seq: int) -> str: + """ИНН физлица/ИП: `00` + 8 цифр номера + две контрольные цифры.""" + body = [int(c) for c in f"00{seq:08d}"] + body.append(_check(body, _W12_1)) + body.append(_check(body, _W12_2)) + return "".join(map(str, body)) + + +def is_valid(inn: str) -> bool: + if not inn.isdigit(): + return False + digits = [int(c) for c in inn] + if len(digits) == 10: + return digits[9] == _check(digits[:9], _W10) + if len(digits) == 12: + return digits[10] == _check(digits[:10], _W12_1) and digits[11] == _check(digits[:11], _W12_2) + return False diff --git a/synthgen/ledger.py b/synthgen/ledger.py new file mode 100644 index 0000000..7c17f25 --- /dev/null +++ b/synthgen/ledger.py @@ -0,0 +1,97 @@ +"""Источник: synthetic-group/gen/ledger.py (#35). Книга проводок компании: двойная запись, сальдо и обороты как свёртка списка.""" + +import csv +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from pathlib import Path + +CENT = Decimal("0.01") + + +@dataclass(frozen=True) +class An: + cp: str | None + contract: str | None = None + item: str | None = None + name_override: str | None = None + inn_override: str | None = None + + +@dataclass(frozen=True) +class Posting: + day: date + dt: str + dt_an: An | None + ct: str + ct_an: An | None + amount: Decimal + memo: str + + +Key = tuple[str, An | None] + + +class Ledger: + def __init__(self, company: str): + self.company = company + self.postings: list[Posting] = [] + self._cache: dict = {} + + def post(self, day, dt, dt_an, ct, ct_an, amount, memo) -> None: + if amount <= 0 or amount != amount.quantize(CENT): + raise ValueError(f"сумма должна быть > 0 с двумя знаками: {amount}") + self.postings.append(Posting(day, dt, dt_an, ct, ct_an, amount, memo)) + self._cache.clear() + + @staticmethod + def _match(code: str, prefix: str | None) -> bool: + return prefix is None or code == prefix or code.startswith(prefix + ".") + + def balance(self, at: date, account_prefix: str | None = None) -> dict[Key, Decimal]: + ck = ("b", at, account_prefix) + if ck not in self._cache: + res: dict[Key, Decimal] = {} + for p in self.postings: + if p.day > at: + continue + if self._match(p.dt, account_prefix): + res[(p.dt, p.dt_an)] = res.get((p.dt, p.dt_an), Decimal(0)) + p.amount + if self._match(p.ct, account_prefix): + res[(p.ct, p.ct_an)] = res.get((p.ct, p.ct_an), Decimal(0)) - p.amount + self._cache[ck] = {k: v for k, v in res.items() if v != 0} + return dict(self._cache[ck]) + + def turnover(self, start: date, end: date, account_prefix: str | None = None) -> dict[Key, tuple[Decimal, Decimal]]: + ck = ("t", start, end, account_prefix) + if ck not in self._cache: + res: dict[Key, list[Decimal]] = {} + for p in self.postings: + if not (start <= p.day <= end): + continue + if self._match(p.dt, account_prefix): + res.setdefault((p.dt, p.dt_an), [Decimal(0), Decimal(0)])[0] += p.amount + if self._match(p.ct, account_prefix): + res.setdefault((p.ct, p.ct_an), [Decimal(0), Decimal(0)])[1] += p.amount + self._cache[ck] = {k: (v[0], v[1]) for k, v in res.items()} + return dict(self._cache[ck]) + + def to_csv(self, path: Path) -> None: + with Path(path).open("w", encoding="utf-8", newline="") as f: + w = csv.writer(f, delimiter=";", lineterminator="\n") + w.writerow("day;dt;dt_cp;dt_contract;dt_item;ct;ct_cp;ct_contract;ct_item;amount;memo".split(";")) + for p in self.postings: + da, ca = p.dt_an, p.ct_an + w.writerow([ + p.day.isoformat(), p.dt, (da.cp or "") if da else "", (da.contract or "") if da else "", + (da.item or "") if da else "", p.ct, (ca.cp or "") if ca else "", (ca.contract or "") if ca else "", + (ca.item or "") if ca else "", f"{p.amount:.2f}", p.memo, + ]) + + +def opening(ledger: Ledger, day: date, account: str, an: An | None, amount: Decimal) -> None: + """Входящий остаток проводкой против вспомогательного счёта 000; amount со знаком (Дт > 0).""" + if amount > 0: + ledger.post(day, account, an, "000", None, amount, "входящий остаток") + elif amount < 0: + ledger.post(day, "000", None, account, an, -amount, "входящий остаток") diff --git a/synthgen/rng.py b/synthgen/rng.py new file mode 100644 index 0000000..d93d4b5 --- /dev/null +++ b/synthgen/rng.py @@ -0,0 +1,19 @@ +"""Детерминированная случайность: одно зерно на каждую подсистему. + +Любая случайность в генераторе — только через `rng_for(seed, *keys)`; модульный `random` запрещён. +Ключи должны иметь стабильный `repr` (строки, числа, даты, кортежи из них). +""" + +import hashlib +import random + + +def rng_for(seed: int, *keys: object) -> random.Random: + """Генератор, зерно = первые 8 байт SHA-256 от repr((seed, *keys)) (порядок байт — big-endian).""" + digest = hashlib.sha256(repr((seed, *keys)).encode("utf-8")).digest() + return random.Random(int.from_bytes(digest[:8], "big")) + + +def stable_hash(text: str, modulo: int) -> int: + """Стабильный хеш строки (SHA-256 → целое → остаток); в отличие от hash() не зависит от PYTHONHASHSEED.""" + return int.from_bytes(hashlib.sha256(text.encode("utf-8")).digest(), "big") % modulo diff --git a/synthgen/tests/conftest.py b/synthgen/tests/conftest.py new file mode 100644 index 0000000..1545bfa --- /dev/null +++ b/synthgen/tests/conftest.py @@ -0,0 +1,28 @@ +"""Общие настройки pytest для synthgen. + +Маркеры: +- `parser` — приёмка парсером tabular-parser из зеркального worktree (переменная `TP_WT`); + без окружения такие тесты пропускаются. +- `slow` — долгие прогоны; по умолчанию пропускаются, запуск: `-m slow`. +""" + +import os +from pathlib import Path + +import pytest + + +def pytest_configure(config): + config.addinivalue_line("markers", "parser: приёмка парсером из зеркального worktree (TP_WT), без окружения — skip") + config.addinivalue_line("markers", "slow: долгий прогон, по умолчанию пропускается, запуск -m slow") + + +def pytest_collection_modifyitems(config, items): + markexpr = config.getoption("-m") or "" + tp_wt = os.environ.get("TP_WT") + parser_ready = bool(tp_wt) and Path(tp_wt).is_dir() + for item in items: + if "slow" in item.keywords and "slow" not in markexpr: + item.add_marker(pytest.mark.skip(reason="долгий прогон: запуск с -m slow")) + if "parser" in item.keywords and not parser_ready: + item.add_marker(pytest.mark.skip(reason="нужен TP_WT (зеркало tabular-parser)")) diff --git a/synthgen/tests/test_config.py b/synthgen/tests/test_config.py new file mode 100644 index 0000000..466df1f --- /dev/null +++ b/synthgen/tests/test_config.py @@ -0,0 +1,85 @@ +from decimal import Decimal +from pathlib import Path + +import pytest +import yaml + +from synthgen.__main__ import main +from synthgen.config import Config, ConfigError, dump_config, load_config +from synthgen.rng import rng_for + +EXAMPLE = Path(__file__).resolve().parent.parent / "examples" / "default.yaml" + + +def test_defaults(): + assert load_config(None, []) == Config() + + +def test_example_matches_defaults(): + assert load_config(EXAMPLE, []) == Config() + + +def test_yaml_and_set_override(tmp_path): + path = tmp_path / "cfg.yaml" + path.write_text("group_size: 7\n", encoding="utf-8") + assert load_config(path, []).group_size == 7 + assert load_config(path, ["group_size=9"]).group_size == 9 + assert load_config(None, ["vgo_mismatch_share=0.25"]).vgo_mismatch_share == Decimal("0.25") + + +def test_dump_roundtrip(tmp_path): + cfg = Config(group_size=12, prefix="DEMO2", vgo_mismatch_share=Decimal("0.25")) + path = tmp_path / "dump.yaml" + path.write_text(dump_config(cfg), encoding="utf-8") + assert load_config(path, []) == cfg + keys = list(yaml.safe_load(dump_config(cfg))) + assert keys == [ + "group_size", "prefix", "periods", "start", "osv_counterparties_mean", + "no_inn_companies", "vgo_mismatch_share", "seed", "out", + ] + + +@pytest.mark.parametrize( + "override", + [ + "group_size=1", + "group_size=41", + "periods=0", + "periods=13", + "start=2025-Q5", + "start=2019-Q4", + "prefix=A", + "prefix=A B", + "prefix=ABCDEFGHIJKLM", + "osv_counterparties_mean=151", + "no_inn_companies=6", + "vgo_mismatch_share=1.5", + "unknown=1", + ], +) +def test_validation(override): + with pytest.raises(ConfigError): + load_config(None, [override]) + + +def test_unknown_key_in_yaml(tmp_path): + path = tmp_path / "cfg.yaml" + path.write_text("unknown: 1\n", encoding="utf-8") + with pytest.raises(ConfigError): + load_config(path, []) + + +def test_cli_bad_config_exit_2(capsys): + assert main(["--set", "group_size=1"]) == 2 + assert capsys.readouterr().err != "" + + +def test_cli_prints_config(capsys): + assert main(["--set", "group_size=7"]) == 0 + assert "group_size: 7" in capsys.readouterr().out + + +def test_rng_for_stable(): + assert rng_for(1, "a").random() == rng_for(1, "a").random() + assert rng_for(1, "a").random() != rng_for(1, "b").random() + assert rng_for(1, "a").random() != rng_for(2, "a").random() diff --git a/synthgen/tests/test_inn.py b/synthgen/tests/test_inn.py new file mode 100644 index 0000000..53a6ef0 --- /dev/null +++ b/synthgen/tests/test_inn.py @@ -0,0 +1,18 @@ +from synthgen import inn + + +def test_inn10_valid_and_region_00(): + for seq in range(1, 30): + value = inn.make_inn10(seq) + assert len(value) == 10 + assert value.startswith("00") + assert inn.is_valid(value) + assert inn.is_valid("0000000001") is False + + +def test_inn12_valid(): + for seq in range(1, 30): + value = inn.make_inn12(seq) + assert len(value) == 12 + assert value.startswith("00") + assert inn.is_valid(value) diff --git a/synthgen/tests/test_ledger.py b/synthgen/tests/test_ledger.py new file mode 100644 index 0000000..c9a0aa0 --- /dev/null +++ b/synthgen/tests/test_ledger.py @@ -0,0 +1,78 @@ +import csv +import os +from datetime import date +from decimal import Decimal +from pathlib import Path + +import pytest + +from synthgen import chart +from synthgen.ledger import An, Ledger, opening + +D = Decimal + + +def test_double_entry(): + lg = Ledger("AGRO") + lg.post(date(2025, 1, 10), "62.01", An("AGRO"), "90.01", None, D("1200.00"), "продажа") + lg.post(date(2025, 2, 10), "51", None, "62.01", An("AGRO"), D("200.00"), "оплата") + opening(lg, date(2025, 1, 1), "51", None, D("500.00")) + opening(lg, date(2025, 1, 1), "60.01", An("AGRO"), D("-70.00")) + for d in (date(2025, 1, 1), date(2025, 1, 31), date(2025, 12, 31)): + assert sum(lg.balance(d).values()) == 0 + + +def test_balance_signs_and_prefix(): + lg = Ledger("X") + lg.post(date(2025, 1, 10), "62.01", An("AGRO"), "90.01", None, D("1200.00"), "") + lg.post(date(2025, 2, 10), "51", None, "62.01", An("AGRO"), D("200.00"), "") + assert lg.balance(date(2025, 3, 31), "62") == {("62.01", An("AGRO")): D("1000.00")} + assert lg.balance(date(2025, 1, 31), "62") == {("62.01", An("AGRO")): D("1200.00")} + + +def test_turnover_window(): + lg = Ledger("X") + lg.post(date(2024, 12, 31), "62.01", An("A"), "90.01", None, D("10.00"), "") + lg.post(date(2025, 1, 1), "62.01", An("A"), "90.01", None, D("20.00"), "") + lg.post(date(2025, 3, 31), "51", None, "62.01", An("A"), D("5.00"), "") + lg.post(date(2025, 4, 1), "62.01", An("A"), "90.01", None, D("40.00"), "") + t = lg.turnover(date(2025, 1, 1), date(2025, 3, 31), "62") + assert t == {("62.01", An("A")): (D("20.00"), D("5.00"))} + + +def test_csv_deterministic(tmp_path): + def build(p: Path): + lg = Ledger("X") + lg.post(date(2025, 1, 10), "62.01", An("A", contract="Д-1"), "90.01", None, D("1.50"), "м") + lg.to_csv(p) + return p.read_bytes() + + a = build(tmp_path / "a.csv") + assert a == build(tmp_path / "b.csv") + rows = list(csv.reader(a.decode("utf-8").splitlines(), delimiter=";")) + assert rows[0] == "day;dt;dt_cp;dt_contract;dt_item;ct;ct_cp;ct_contract;ct_item;amount;memo".split(";") + assert rows[1][:3] == ["2025-01-10", "62.01", "A"] + + +def test_rejects_bad_amount(): + lg = Ledger("X") + with pytest.raises(ValueError): + lg.post(date(2025, 1, 1), "51", None, "62.01", None, D("0"), "") + + +def test_chart_helpers(): + assert chart.parent("76.07.1") == "76.07" + assert chart.parent("76.07") == "76" + assert chart.parent("58.01") == "58" + assert chart.is_off_balance("001") and chart.is_off_balance("011") + assert not chart.is_off_balance("01") + + +def test_chart_codes_in_core_seed(): + ws = Path(os.environ.get("ARM_KA_WORKSPACE", "/Users/andrey.epifancev/projects/awp-ca")) + seed = ws / "arm-core/api/seed/core/chart_of_accounts.csv" + if not seed.exists(): + pytest.skip("нет seed плана счетов") + codes = {r["code"] for r in csv.DictReader(seed.open(encoding="utf-8"))} + missing = [c for c in chart.ACCOUNTS if c != "000" and c not in codes] + assert not missing diff --git a/synthgen/xlsx.py b/synthgen/xlsx.py new file mode 100644 index 0000000..46fd2fc --- /dev/null +++ b/synthgen/xlsx.py @@ -0,0 +1,37 @@ +"""Источник: synthetic-group/gen/xlsx.py (#35). Детерминированное сохранение xlsx: фиксированные метки книги и записей zip.""" + +import datetime +import io +import re +import zipfile +from pathlib import Path + +import openpyxl + +FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0) +ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0) +CREATOR = "arm-ka synthetic" +_FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode() +_CORE_PART = "docProps/core.xml" +_MODIFIED_RE = re.compile(rb"(]*>)[^<]*()") + + +def save_deterministic(wb: openpyxl.Workbook, path: Path) -> None: + wb.properties.creator = CREATOR + wb.properties.lastModifiedBy = CREATOR + wb.properties.created = FIXED_TIME + wb.properties.modified = FIXED_TIME + raw = io.BytesIO() + wb.save(raw) + out = io.BytesIO() + with zipfile.ZipFile(raw) as source, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target: + for info in source.infolist(): + clone = zipfile.ZipInfo(info.filename, date_time=ZIP_DATE_TIME) + clone.compress_type = zipfile.ZIP_DEFLATED + clone.external_attr = info.external_attr + content = source.read(info.filename) + if info.filename == _CORE_PART: + content = _MODIFIED_RE.sub(rb"\g<1>" + _FIXED_W3CDTF + rb"\g<2>", content) + target.writestr(clone, content) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(out.getvalue())