- 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:
parent
03edcb4fdb
commit
801d9948ca
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
~$*
|
||||
*.tmp
|
||||
.superpowers/
|
||||
out/
|
||||
|
||||
@ -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 одноразово — перегенерировать образцы |
|
||||
|
||||
1
synthgen/__init__.py
Normal file
1
synthgen/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Генератор синтетического набора АРМ КА (#38): ГК, ОСВ, ББ/ОФР, ВГО, ЕЦП — по спеке docs/superpowers/specs/2026-11-12-synthgen-design.md."""
|
||||
40
synthgen/__main__.py
Normal file
40
synthgen/__main__.py
Normal 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
79
synthgen/chart.py
Normal 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
151
synthgen/config.py
Normal 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)
|
||||
12
synthgen/examples/default.yaml
Normal file
12
synthgen/examples/default.yaml
Normal 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
36
synthgen/inn.py
Normal 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
97
synthgen/ledger.py
Normal 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
19
synthgen/rng.py
Normal 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
|
||||
28
synthgen/tests/conftest.py
Normal file
28
synthgen/tests/conftest.py
Normal 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)"))
|
||||
85
synthgen/tests/test_config.py
Normal file
85
synthgen/tests/test_config.py
Normal 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()
|
||||
18
synthgen/tests/test_inn.py
Normal file
18
synthgen/tests/test_inn.py
Normal 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)
|
||||
78
synthgen/tests/test_ledger.py
Normal file
78
synthgen/tests/test_ledger.py
Normal 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
37
synthgen/xlsx.py
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user