- 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
|
*.tmp
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
out/
|
||||||
|
|||||||
@ -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
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