synthgen: каркас, настройки, копии модулей #35 (#38)

- synthgen/config.py: Config, load_config (YAML + --set), dump_config, проверки диапазонов §3
- synthgen/rng.py: rng_for (SHA-256 → Random), stable_hash
- synthgen/__main__.py: разбор --config/--set/--out/--force, ошибка конфигурации → код 2
- synthgen/examples/default.yaml: все ключи §3 со значениями по умолчанию
- копии из synthetic-group/gen (#35): inn, xlsx, ledger, chart (+80.09, 91.09 из seed arm-core)
- тесты: config, inn, ledger; conftest с маркерами parser/slow
- .gitignore: out/
- спека: решения 15–16

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 23:35:17 +04:00
parent 03edcb4fdb
commit 801d9948ca
15 changed files with 684 additions and 0 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
~$* ~$*
*.tmp *.tmp
.superpowers/ .superpowers/
out/

View File

@ -276,3 +276,5 @@ README прогона, точные субсчета внутри перечис
| 8 | 12.11.2026 | Значения по умолчанию §3 (5 компаний, `SYN`, 4 периода с 2025-Q1, 60 контрагентов, 1 без ИНН, доля 0.1) | разумный стартовый объём: «есть что смотреть», прогон быстрый | никакой — меняется настройкой | | 8 | 12.11.2026 | Значения по умолчанию §3 (5 компаний, `SYN`, 4 периода с 2025-Q1, 60 контрагентов, 1 без ИНН, доля 0.1) | разумный стартовый объём: «есть что смотреть», прогон быстрый | никакой — меняется настройкой |
| 13 | 12.11.2026 | Тесты жизненного цикла расхождений ВГО проверяют снятие 31 января, только если январь следующего года внутри периодов книги; иначе — только удержание до конца года (R-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` | нет | | 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 одноразово — перегенерировать образцы |

1
synthgen/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Генератор синтетического набора АРМ КА (#38): ГК, ОСВ, ББ/ОФР, ВГО, ЕЦП — по спеке docs/superpowers/specs/2026-11-12-synthgen-design.md."""

40
synthgen/__main__.py Normal file
View File

@ -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())

79
synthgen/chart.py Normal file
View File

@ -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

151
synthgen/config.py Normal file
View File

@ -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)

View File

@ -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/<prefix> (или --out)

36
synthgen/inn.py Normal file
View File

@ -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

97
synthgen/ledger.py Normal file
View File

@ -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, "входящий остаток")

19
synthgen/rng.py Normal file
View File

@ -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

View File

@ -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)"))

View File

@ -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()

View File

@ -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)

View File

@ -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

37
synthgen/xlsx.py Normal file
View File

@ -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"(<dcterms:modified[^>]*>)[^<]*(</dcterms:modified>)")
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())