synthgen: периоды, внешние контрагенты, экономика компаний (#38)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f215bab9b0
commit
418f066bd8
@ -282,3 +282,8 @@ README прогона, точные субсчета внутри перечис
|
||||
| 18 | 12.11.2026 | Вариантное написание члена группы в ОСВ без ИНН: перестановка `<корень> <prefix> <форма>` без кавычек (`group.variant_spelling`); вариант выбирает `rng_for(seed, "group", prefix)` среди остальных членов | нужно написание, которое не совпадёт с `name` и `name_1c` посимвольно, но читается человеком; точный механизм сопоставления ядра по имени не проверялся | если ядро нормализует порядок слов и сопоставит автоматически — ручного сопоставления не будет, поменять правило варианта |
|
||||
| 19 | 12.11.2026 | Доли головной: дочерняя без миноритария — `1.00`; у 1–2 дочерних (при `group_size=2` — у единственной) доля 0.51–0.99, миноритарий — внешняя организация словаря (юрлицо не банк) | §4 тестов требует `head_share ∈ [0.51, 1.00]` и миноритария у 1–2; миноритарий остаётся внешним контрагентом, источник долей — только данные группы | миноритарий не как контрагент в ОСВ — не будет связи, исключить из `externals` |
|
||||
| 20 | 12.11.2026 | В ЕЦП «Базовый РФ ГК в CRM» — `Ростовский региональный филиал`, `Тип` — `ЮЛ`, как в #35; имя группы в колонке «Группа компаний» — `ГК <prefix>` (в #35 было без «ГК») | импорт ядра принимает любые значения (обязательны ИНН, iD ГК, Группа компаний, Организация, Тип); `ГК <prefix>` задано §4.2 | нет |
|
||||
| 21 | 12.11.2026 | Веса контрагентов — детерминированная кривая Лоренца Парето (доля верхних p = p^(1−1/α), α=1.2, шум ±15 %), а не выборка `paretovariate` | выборка Парето на 43 контрагентах давала долю верхних 10 % от 23 % до 97 % по зерну; тест формы (≥ 50 %) стал бы случайным | распределение чуть «глаже» настоящей выборки Парето |
|
||||
| 22 | 12.11.2026 | `Ledger`: индекс накопленных итогов по (счёт, аналитика) для `balance` на дату не раньше последней проводки; `Posting`/`An` — `slots=True` | закрытие месяца спрашивает сальдо по 90/91/68/99 после каждого месяца; полный проход по проводкам давал O(N²) на крупном прогоне (600 тыс. проводок при 15 компаниях, 6 периодах, 150 контрагентах) | запрос сальдо на прошлую дату всё ещё идёт проходом по проводкам (кэшируется) |
|
||||
| 23 | 12.11.2026 | В `chart.ACCOUNTS` добавлены 01.01, 67.01, 67.02; названия всех субсчетов сверены с seed arm-core (`api/seed/core/chart_of_accounts.csv`) — расхождений нет (T1.7) | экономика использует 01.01 и кредиты/проценты 67.01/67.02, которых не было в копии #35 | нет |
|
||||
| 24 | 12.11.2026 | Поведение контрагентов: ~72 % «обычные» (оплата в следующем месяце), ~20 % «только обороты» (оплата в месяце), ~5 % «только сальдо» (входящий остаток), ~3 % «аванс» (62.02/60.02 обратного знака, не меньше одного при n ≥ 8); трое крупнейших — всегда обычные. Для 58 «обычные» и «только сальдо» — остаток займа 58.03, «только обороты» — выдан и возвращён в месяце. Без внешних контрагентов (mean = 0) операции идут без аналитики контрагента | §5.3 задаёт доли, но не привязку к субсчетам; механика — из #35 | доли в ОСВ могут отличаться от реальной выгрузки банка — поменять константы в `economy._classes` |
|
||||
| 25 | 12.11.2026 | Головная в `economy` — холдинг: выручка ~50 млн ₽/год, ОС 1.5× выручки, закупок 10 %; вложений 58.01 и участников в 80 нет — 80 проводится без аналитики на 2 % годовой выручки (их добавит Task 4 вместе с операциями внутри группы) | вложения 58.01 по смыслу — операции внутри группы (гудвилл 58.01 ↔ 80 дочерних), не внешняя экономика | Task 4 должен перепровести 80 по участникам и 58.01 головной |
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
ACCOUNTS: dict[str, str] = {
|
||||
"000": "Вспомогательный счет",
|
||||
"01": "Основные средства",
|
||||
"01.01": "Основные средства в организации",
|
||||
"02": "Амортизация основных средств",
|
||||
"03": "Доходные вложения в материальные ценности",
|
||||
"08": "Вложения во внеоборотные активы",
|
||||
@ -28,6 +29,8 @@ ACCOUNTS: dict[str, str] = {
|
||||
"66.02": "Проценты по краткосрочным кредитам",
|
||||
"66.03": "Краткосрочные займы",
|
||||
"66.04": "Проценты по краткосрочным займам",
|
||||
"67.01": "Долгосрочные кредиты",
|
||||
"67.02": "Проценты по долгосрочным кредитам",
|
||||
"67.03": "Долгосрочные займы",
|
||||
"67.04": "Проценты по долгосрочным займам",
|
||||
"68": "Расчеты по налогам и сборам",
|
||||
|
||||
76
synthgen/counterparties.py
Normal file
76
synthgen/counterparties.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""Внешние контрагенты в ОСВ (спека §5.3): сколько и кто, для каждой пары (компания, счёт).
|
||||
|
||||
Число `n` — отрицательное биномиальное (гамма-пуассоновская смесь с дисперсией ≈ 2 × матожидание), матожидание
|
||||
`mean × w(счёт) / среднее_w`, так что среднее `n` по файлам счетов 58/60/62/66/67/76 ≈ `osv_counterparties_mean`.
|
||||
Потолки: 58/60/62/76 — словарь без банков и компаний группы, 66/67 — банки. Недобор из-за потолка перераспределяется на
|
||||
остальные файлы того же счёта (решение 9); если потолок всё же срезал выборку — `capped=True`.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from synthgen.config import Config
|
||||
from synthgen.dictionary import Org
|
||||
from synthgen.group import Group
|
||||
from synthgen.rng import rng_for
|
||||
|
||||
OSV_ACCOUNTS = ("01", "58", "60", "62", "66", "67", "76", "80", "90")
|
||||
WEIGHTS = {"60": 1.6, "62": 1.6, "76": 1.0, "66": 0.1, "67": 0.05, "58": 0.1}
|
||||
BANK_ACCOUNTS = ("66", "67")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CounterpartyPlan:
|
||||
by_file: dict[tuple[str, str], list[Org]] = field(default_factory=dict) # (код компании, корень счёта) → контрагенты
|
||||
capped: bool = False # потолок словаря срезал выборку хотя бы в одном файле
|
||||
|
||||
|
||||
def _poisson(rng: random.Random, lam: float) -> int:
|
||||
if lam <= 0:
|
||||
return 0
|
||||
if lam > 30: # нормальное приближение: точное для нашей задачи, а цикл Кнута на больших λ медленный
|
||||
return max(0, round(rng.gauss(lam, math.sqrt(lam))))
|
||||
limit, k, p = math.exp(-lam), 0, 1.0
|
||||
while True:
|
||||
p *= rng.random()
|
||||
if p <= limit:
|
||||
return k
|
||||
k += 1
|
||||
|
||||
|
||||
def _draw_n(rng: random.Random, mu: float) -> int:
|
||||
"""Отрицательное биномиальное с матожиданием `mu` и дисперсией 2·mu: Poisson(Gamma(mu, 1))."""
|
||||
if mu <= 0:
|
||||
return 0
|
||||
return _poisson(rng, rng.gammavariate(mu, 1.0))
|
||||
|
||||
|
||||
def plan(cfg: Config, group: Group) -> CounterpartyPlan:
|
||||
codes = [m.code for m in group.members]
|
||||
avg_w = sum(WEIGHTS.values()) / len(WEIGHTS)
|
||||
capped = False
|
||||
by_file: dict[tuple[str, str], list[Org]] = {}
|
||||
for acct, w in WEIGHTS.items():
|
||||
pool = group.banks if acct in BANK_ACCOUNTS else group.externals
|
||||
cap = len(pool)
|
||||
mu = cfg.osv_counterparties_mean * w / avg_w
|
||||
want = [_draw_n(rng_for(cfg.seed, "cp-count", code, acct), mu) for code in codes]
|
||||
got = [min(n, cap) for n in want]
|
||||
shortfall = sum(want) - sum(got)
|
||||
if shortfall > 0:
|
||||
capped = True
|
||||
room = [i for i, n in enumerate(got) if n < cap]
|
||||
while shortfall > 0 and room: # круговое доведение недобора до потолка файлов, где есть место
|
||||
nxt = []
|
||||
for i in room:
|
||||
if shortfall == 0:
|
||||
break
|
||||
got[i] += 1
|
||||
shortfall -= 1
|
||||
if got[i] < cap:
|
||||
nxt.append(i)
|
||||
room = nxt
|
||||
for code, n in zip(codes, got):
|
||||
by_file[(code, acct)] = rng_for(cfg.seed, "cp-set", code, acct).sample(pool, n) if n else []
|
||||
return CounterpartyPlan(by_file=by_file, capped=capped)
|
||||
416
synthgen/economy.py
Normal file
416
synthgen/economy.py
Normal file
@ -0,0 +1,416 @@
|
||||
"""Экономика компаний группы с внешними контрагентами (спека §5.2–5.3).
|
||||
|
||||
Источник образца потоков и закрытия месяца: synthetic-group/gen/economy.py (#35); контрагенты здесь — из плана
|
||||
`counterparties.plan`, а не фиксированные SUP_A/BUY_G.
|
||||
|
||||
Порядок построения (на него опирается Task 4, операции внутри группы):
|
||||
open_books(...) входящие остатки на book_start (31.12 перед первым периодом);
|
||||
для каждого месяца: post_month(...) внешние операции месяца,
|
||||
hook(...) операции внутри группы (extra),
|
||||
close_month(...) закрытие 26/44 → 90, 90/91 → 99, налог, в декабре 99 → 84.
|
||||
`build_ledgers` — эта последовательность целиком.
|
||||
|
||||
Аналитика контрагента: `An(cp=<ИНН Org>)` для внешних, `An(cp=<код члена>)` для компаний группы (последние — Task 4).
|
||||
Модель компании (веса, классы контрагентов, профиль) строится один раз в `open_books` и лежит в `Ledger.meta["model"]`.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
import math
|
||||
|
||||
from synthgen.config import Config
|
||||
from synthgen.counterparties import CounterpartyPlan
|
||||
from synthgen.group import Group, Member
|
||||
from synthgen.ledger import CENT, An, Ledger, opening
|
||||
from synthgen.periods import Period, book_end, book_start
|
||||
from synthgen.rng import rng_for
|
||||
|
||||
D = Decimal
|
||||
VAT = D("0.2")
|
||||
PROFIT_TAX = D("0.2")
|
||||
SOCIAL = D("0.3")
|
||||
GROWTH = D("0.07") # рост выручки в год
|
||||
FNS = An("FNS")
|
||||
PARETO_ALPHA = 1.2
|
||||
|
||||
# kind словаря → (доля закупок товара к выручке, доля расходов на продажу, доля зарплаты, ОС к годовой выручке)
|
||||
KIND_PROFILE: dict[str, tuple[str, str, str, str]] = {
|
||||
"агро": ("0.55", "0.05", "0.08", "0.5"),
|
||||
"торговля": ("0.65", "0.07", "0.06", "0.3"),
|
||||
"производство": ("0.50", "0.06", "0.10", "0.5"),
|
||||
"строительство": ("0.50", "0.05", "0.12", "0.5"),
|
||||
"логистика": ("0.35", "0.08", "0.12", "0.5"),
|
||||
"услуги": ("0.15", "0.06", "0.20", "0.3"),
|
||||
}
|
||||
HEAD_PROFILE = ("0.10", "0.05", "0.25", "1.5") # головная — холдинг: малая выручка, большие активы
|
||||
MEDIAN_REVENUE = 1_000_000_000
|
||||
HEAD_REVENUE = 50_000_000
|
||||
FLOW_ACCOUNTS = ("62", "60", "76") # с потоками по контрагентам (продажи, закупки, прочие поставщики)
|
||||
|
||||
|
||||
def q(x: Decimal) -> Decimal:
|
||||
return x.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def vat_of(gross: Decimal) -> Decimal:
|
||||
return q(gross * 20 / 120)
|
||||
|
||||
|
||||
def last_day(year: int, month: int) -> date:
|
||||
return date(year + (month == 12), month % 12 + 1, 1) - timedelta(days=1)
|
||||
|
||||
|
||||
def _prev(year: int, month: int) -> tuple[int, int]:
|
||||
return (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileModel:
|
||||
"""Контрагенты одного файла (компания × счёт): аналитика, веса Парето, класс поведения."""
|
||||
|
||||
ans: list[An | None]
|
||||
w: list[Decimal] # по убыванию
|
||||
cls: list[str] # normal (оплата в следующем месяце) / turn (оплата в месяце) / bal (только сальдо) / rev (аванс .02)
|
||||
w_all: Decimal = D(0)
|
||||
w_active: Decimal = D(0)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.w_all = sum(self.w, D(0))
|
||||
self.w_active = sum((w for w, c in zip(self.w, self.cls) if c in ("normal", "turn")), D(0))
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompanyModel:
|
||||
code: str
|
||||
seed: int
|
||||
first_year: int
|
||||
rev_year: Decimal
|
||||
cogs: Decimal
|
||||
sell: Decimal
|
||||
salary: Decimal
|
||||
ppe_ratio: Decimal
|
||||
files: dict[str, FileModel] = field(default_factory=dict)
|
||||
banks: dict[str, FileModel] = field(default_factory=dict) # "66"/"67" → банки и веса долей долга
|
||||
|
||||
|
||||
def _weights(rng, n: int) -> list[Decimal]:
|
||||
"""Веса контрагентов по кривой Лоренца Парето: доля верхних p контрагентов = p^(1 − 1/α); α = 1.2 даёт
|
||||
~68 % оборота у верхних 10 %. Детерминированная кривая вместо выборки Парето (иначе доля верхних 10 % прыгает
|
||||
от 23 % до 97 % по зерну), небольшой шум — чтобы веса разных файлов не совпадали."""
|
||||
expo = 1 - 1 / PARETO_ALPHA
|
||||
shares = [((k / n) ** expo - ((k - 1) / n) ** expo) * rng.uniform(0.85, 1.15) for k in range(1, n + 1)]
|
||||
return [D(max(1, round(w * 1e9))) for w in sorted(shares, reverse=True)]
|
||||
|
||||
|
||||
def _classes(rng, acct: str, n: int) -> list[str]:
|
||||
out = []
|
||||
for i in range(n):
|
||||
u = rng.random()
|
||||
if i < 3: # крупнейшие контрагенты — обычные: оплата в следующем месяце
|
||||
out.append("normal")
|
||||
elif u < 0.03:
|
||||
out.append("rev" if acct in ("60", "62") else "bal")
|
||||
elif u < 0.08:
|
||||
out.append("bal")
|
||||
elif u < 0.28:
|
||||
out.append("turn")
|
||||
else:
|
||||
out.append("normal")
|
||||
if acct in ("60", "62") and n >= 8 and "rev" not in out:
|
||||
out[n // 2] = "rev" # хотя бы один контрагент с обратным знаком сальдо (аванс/переплата, §5.3)
|
||||
return out
|
||||
|
||||
|
||||
def _build_file(cfg: Config, code: str, acct: str, orgs: list) -> FileModel:
|
||||
n = len(orgs)
|
||||
if n == 0: # нет контрагентов в плане (mean = 0): операции идут без аналитики контрагента
|
||||
return FileModel([None], [D(1)], ["normal"])
|
||||
ans = [An(cp=o.inn) for o in orgs]
|
||||
return FileModel(ans, _weights(rng_for(cfg.seed, "cp-weights", code, acct), n), _classes(rng_for(cfg.seed, "cp-class", code, acct), acct, n))
|
||||
|
||||
|
||||
def _build_model(cfg: Config, plan: CounterpartyPlan, member: Member, first_year: int) -> CompanyModel:
|
||||
code = member.code
|
||||
scale = rng_for(cfg.seed, "scale", code)
|
||||
if member.is_head:
|
||||
rev = HEAD_REVENUE * math.exp(scale.gauss(0, 0.5))
|
||||
prof = HEAD_PROFILE
|
||||
else:
|
||||
rev = MEDIAN_REVENUE * math.exp(scale.gauss(0, math.log(10) / 2)) # ×10 в обе стороны на ±2σ
|
||||
prof = KIND_PROFILE[member.org.kind]
|
||||
rev = min(max(rev, 5e7), 2e10)
|
||||
model = CompanyModel(code, cfg.seed, first_year, D(int(rev)), D(prof[0]), D(prof[1]), D(prof[2]), D(prof[3]))
|
||||
for acct in ("58", *FLOW_ACCOUNTS):
|
||||
model.files[acct] = _build_file(cfg, code, acct, plan.by_file.get((code, acct), []))
|
||||
for acct in ("66", "67"):
|
||||
orgs = plan.by_file.get((code, acct), [])
|
||||
if orgs:
|
||||
model.banks[acct] = _build_file(cfg, code, acct, orgs)
|
||||
return model
|
||||
|
||||
|
||||
# ---------- суммы месяца ----------
|
||||
|
||||
def _noise(m: CompanyModel, tag: str, year: int, month: int) -> Decimal:
|
||||
return D(rng_for(m.seed, "noise", m.code, tag, year, month).randint(92, 108)) / 100
|
||||
|
||||
|
||||
def _rev_month(m: CompanyModel, year: int, month: int) -> Decimal:
|
||||
"""Чистая выручка месяца (без НДС)."""
|
||||
growth = 1 + GROWTH * (year - m.first_year)
|
||||
return q(m.rev_year / 12 * growth * _noise(m, "rev", year, month))
|
||||
|
||||
|
||||
def _total(m: CompanyModel, acct: str, year: int, month: int) -> Decimal:
|
||||
"""Брутто-оборот месяца по счёту расчётов: 62 — продажи, 60 — закупки товара, 76 — прочие поставщики."""
|
||||
rev = _rev_month(m, year, month)
|
||||
share = {"62": D(1), "60": m.cogs, "76": m.sell}[acct]
|
||||
return q(rev * share * (1 + VAT))
|
||||
|
||||
|
||||
def _split(fm: FileModel, total: Decimal, classes: tuple[str, ...]) -> list[tuple[int, Decimal]]:
|
||||
"""Раскладка оборота по контрагентам указанных классов с весами Парето; нулевые суммы отбрасываются."""
|
||||
w_sum = sum((w for w, c in zip(fm.w, fm.cls) if c in classes), D(0))
|
||||
if w_sum == 0 or total <= 0:
|
||||
return []
|
||||
out = []
|
||||
for i, (w, c) in enumerate(zip(fm.w, fm.cls)):
|
||||
if c in classes:
|
||||
a = q(total * w / w_sum)
|
||||
if a > 0:
|
||||
out.append((i, a))
|
||||
return out
|
||||
|
||||
|
||||
def _side(fm: FileModel, ref: Decimal, i: int, factor: str) -> Decimal:
|
||||
"""Сальдо «без оборотов» контрагента: доля месячного оборота по весу среди всех контрагентов файла."""
|
||||
return q(ref * fm.w[i] / fm.w_all * D(factor))
|
||||
|
||||
|
||||
# ---------- входящие остатки ----------
|
||||
|
||||
def opening_lines(m: CompanyModel) -> list[tuple[str, An | None, Decimal]]:
|
||||
py, pm = m.first_year - 1, 12
|
||||
ppe = q(m.rev_year * m.ppe_ratio)
|
||||
lines: list[tuple[str, An | None, Decimal]] = [
|
||||
("01.01", None, ppe),
|
||||
("02", None, -q(ppe * D("0.3"))),
|
||||
("51", None, q(m.rev_year * D("0.06"))),
|
||||
]
|
||||
if m.cogs:
|
||||
lines.append(("41", None, q(m.rev_year * m.cogs * D("0.15"))))
|
||||
# 62: дебиторка — оплата прошлого месяца; bal — старая задолженность; rev — аванс (62.02, кредит)
|
||||
for acct, sign, sub, sub_rev in (("62", 1, "62.01", "62.02"), ("60", -1, "60.01", "60.02"), ("76", -1, "76.05", None)):
|
||||
fm = m.files[acct]
|
||||
total = _total(m, acct, py, pm)
|
||||
if acct != "62" and total == 0:
|
||||
continue
|
||||
ref = total if total else q(_rev_month(m, py, pm))
|
||||
for i, a in _split(fm, total, ("normal", "turn")):
|
||||
if fm.cls[i] == "normal": # оплата в следующем месяце; turn закрыт в своём месяце
|
||||
lines.append((sub, fm.ans[i], sign * a))
|
||||
for i, c in enumerate(fm.cls):
|
||||
if c == "bal":
|
||||
lines.append((sub, fm.ans[i], sign * _side(fm, ref, i, "0.5")))
|
||||
elif c == "rev" and sub_rev:
|
||||
lines.append((sub_rev, fm.ans[i], -sign * _side(fm, ref, i, "0.3")))
|
||||
fm58 = m.files["58"]
|
||||
ref58 = q(_rev_month(m, py, pm))
|
||||
for i, c in enumerate(fm58.cls):
|
||||
if c in ("normal", "bal") and fm58.ans[i] is not None:
|
||||
lines.append(("58.03", fm58.ans[i], _side(fm58, ref58, i, "3")))
|
||||
for acct, share in (("66", "0.12"), ("67", "0.08")):
|
||||
fm = m.banks.get(acct)
|
||||
if fm:
|
||||
for i, a in enumerate(_split_all(fm, m.rev_year * D(share))):
|
||||
if a > 0:
|
||||
lines.append((f"{acct}.01", fm.ans[i], -a))
|
||||
lines.append(("80", None, -q(m.rev_year * D("0.02"))))
|
||||
return lines
|
||||
|
||||
|
||||
def _split_all(fm: FileModel, total: Decimal) -> list[Decimal]:
|
||||
return [q(total * w / fm.w_all) for w in fm.w]
|
||||
|
||||
|
||||
def open_books(cfg: Config, group: Group, ps: list[Period], plan: CounterpartyPlan) -> dict[str, Ledger]:
|
||||
"""Книги членов группы с входящими остатками на `book_start(ps)`; 84 — балансирующая строка."""
|
||||
day = book_start(ps)
|
||||
ledgers: dict[str, Ledger] = {}
|
||||
for member in group.members:
|
||||
model = _build_model(cfg, plan, member, ps[0].year)
|
||||
lg = Ledger(member.code)
|
||||
lg.meta["model"] = model
|
||||
lines = opening_lines(model)
|
||||
total = sum((v for _, _, v in lines), D(0))
|
||||
if total <= 0:
|
||||
raise ValueError(f"{member.code}: активы не больше обязательств, 84 стал бы дебетовым")
|
||||
for acct, an, v in lines:
|
||||
opening(lg, day, acct, an, v)
|
||||
opening(lg, day, "84", None, -total)
|
||||
ledgers[member.code] = lg
|
||||
return ledgers
|
||||
|
||||
|
||||
# ---------- операции месяца ----------
|
||||
|
||||
def _flow(lg: Ledger, m: CompanyModel, acct: str, year: int, month: int) -> None:
|
||||
"""Продажи (62), закупки товара (60) или прочие расходы (76) месяца и оплата контрагентам."""
|
||||
fm = m.files[acct]
|
||||
py, pm = _prev(year, month)
|
||||
d = lambda n: date(year, month, n) # noqa: E731
|
||||
cur = _split(fm, _total(m, acct, year, month), ("normal", "turn"))
|
||||
prev = {i: a for i, a in _split(fm, _total(m, acct, py, pm), ("normal", "turn")) if fm.cls[i] == "normal"}
|
||||
vat_total = D(0)
|
||||
net_total = D(0)
|
||||
sub = {"62": "62.01", "60": "60.01", "76": "76.05"}[acct]
|
||||
for i, gross in cur:
|
||||
an = fm.ans[i]
|
||||
vat = vat_of(gross)
|
||||
net = gross - vat
|
||||
vat_total += vat
|
||||
net_total += net
|
||||
if acct == "62":
|
||||
lg.post(d(10), sub, an, "90.01", None, gross, "реализация")
|
||||
else:
|
||||
exp = "41" if acct == "60" else "44"
|
||||
lg.post(d(5), exp, None, sub, an, net, "закупка")
|
||||
if vat > 0:
|
||||
lg.post(d(5), "19", None, sub, an, vat, "НДС по закупке")
|
||||
if vat_total > 0:
|
||||
if acct == "62":
|
||||
lg.post(d(10), "90.03", None, "68", FNS, vat_total, "НДС с реализации")
|
||||
else:
|
||||
lg.post(d(5), "68", FNS, "19", None, vat_total, "НДС к вычету")
|
||||
if acct == "60" and net_total > 0:
|
||||
lg.post(d(5), "90.02", None, "41", None, net_total, "себестоимость")
|
||||
pay_day = d(20) if acct == "62" else d(15)
|
||||
for i, gross in cur:
|
||||
if fm.cls[i] == "turn":
|
||||
_settle(lg, acct, sub, fm.ans[i], gross, pay_day)
|
||||
for i, amount in prev.items():
|
||||
_settle(lg, acct, sub, fm.ans[i], amount, pay_day)
|
||||
|
||||
|
||||
def _settle(lg: Ledger, acct: str, sub: str, an: An | None, amount: Decimal, day: date) -> None:
|
||||
if acct == "62":
|
||||
lg.post(day, "51", None, sub, an, amount, "поступление от покупателя")
|
||||
else:
|
||||
lg.post(day, sub, an, "51", None, amount, "оплата поставщику")
|
||||
|
||||
|
||||
def _loans(lg: Ledger, m: CompanyModel, acct: str, year: int, month: int) -> None:
|
||||
"""Проценты банку по кредитам: начислены и уплачены в конце месяца (остаток по .02 не копится)."""
|
||||
fm = m.banks.get(acct)
|
||||
if not fm:
|
||||
return
|
||||
share = D("0.12") if acct == "66" else D("0.08")
|
||||
day = date(year, month, 28)
|
||||
for i, principal in enumerate(_split_all(fm, m.rev_year * share)):
|
||||
interest = q(principal * D("0.12") / 12)
|
||||
if interest > 0:
|
||||
an = fm.ans[i]
|
||||
lg.post(day, "91.02", An(an.cp, item="Проценты"), f"{acct}.02", an, interest, "проценты по кредиту")
|
||||
lg.post(day, f"{acct}.02", an, "51", None, interest, "уплата процентов")
|
||||
|
||||
|
||||
def _issued_loans(lg: Ledger, m: CompanyModel, year: int, month: int) -> None:
|
||||
"""58.03: часть контрагентов — займ выдан и возвращён в месяце (только обороты); остальные — сальдо с открытия."""
|
||||
fm = m.files["58"]
|
||||
amounts = _split(fm, q(_rev_month(m, year, month) * D("0.05")), ("turn",))
|
||||
for i, a in amounts:
|
||||
lg.post(date(year, month, 12), "58.03", fm.ans[i], "51", None, a, "выдан займ")
|
||||
lg.post(date(year, month, 22), "51", None, "58.03", fm.ans[i], a, "возврат займа")
|
||||
|
||||
|
||||
def post_month(ledgers: dict[str, Ledger], cfg: Config, group: Group, plan: CounterpartyPlan, year: int, month: int) -> None:
|
||||
"""Внешние операции месяца всех членов группы (до операций внутри группы и закрытия)."""
|
||||
for code in sorted(ledgers):
|
||||
lg = ledgers[code]
|
||||
m: CompanyModel = lg.meta["model"]
|
||||
d = lambda n: date(year, month, n) # noqa: E731
|
||||
for acct in ("60", "76", "62"):
|
||||
if _total(m, acct, year, month) > 0:
|
||||
_flow(lg, m, acct, year, month)
|
||||
_issued_loans(lg, m, year, month)
|
||||
sal = q(_rev_month(m, year, month) * m.salary)
|
||||
if sal > 0:
|
||||
soc = q(sal * SOCIAL)
|
||||
lg.post(d(25), "26", None, "70", None, sal, "начислена зарплата")
|
||||
lg.post(d(25), "26", None, "69", None, soc, "страховые взносы")
|
||||
lg.post(d(26), "70", None, "51", None, sal, "выплата зарплаты")
|
||||
lg.post(d(26), "69", None, "51", None, soc, "уплата взносов")
|
||||
lg.post(d(28), "26", None, "02", None, q(m.rev_year * m.ppe_ratio / 120), "амортизация")
|
||||
_loans(lg, m, "66", year, month)
|
||||
_loans(lg, m, "67", year, month)
|
||||
|
||||
|
||||
def _close_all(lg: Ledger, day: date, prefix: str) -> Decimal:
|
||||
"""Закрывает все субсчета prefix на 99; возвращает итог (Кт > 0 — прибыль)."""
|
||||
result = D(0)
|
||||
for (acct, an), bal in sorted(lg.balance(day, prefix).items(), key=lambda kv: (kv[0][0], str(kv[0][1]))):
|
||||
if bal > 0:
|
||||
lg.post(day, "99", None, acct, an, bal, "закрытие на 99")
|
||||
else:
|
||||
lg.post(day, acct, an, "99", None, -bal, "закрытие на 99")
|
||||
result -= bal
|
||||
return result
|
||||
|
||||
|
||||
def close_month(ledgers: dict[str, Ledger], year: int, month: int) -> None:
|
||||
day = last_day(year, month)
|
||||
for code in sorted(ledgers):
|
||||
lg = ledgers[code]
|
||||
vat_due = -lg.balance(day, "68").get(("68", FNS), D(0))
|
||||
if vat_due > 0:
|
||||
lg.post(day, "68", FNS, "51", None, vat_due, "уплата НДС")
|
||||
for cost, target in (("26", "90.08"), ("44", "90.07")):
|
||||
for (acct, an), bal in lg.balance(day, cost).items():
|
||||
if bal > 0:
|
||||
lg.post(day, target, None, acct, an, bal, "закрытие затрат")
|
||||
result = _close_all(lg, day, "90") + _close_all(lg, day, "91")
|
||||
tax = q(result * PROFIT_TAX)
|
||||
if tax > 0:
|
||||
lg.post(day, "99", None, "68.04", FNS, tax, "налог на прибыль")
|
||||
elif tax < 0:
|
||||
lg.post(day, "68.04", FNS, "99", None, -tax, "корректировка налога на прибыль")
|
||||
if month % 3 == 0:
|
||||
due = -lg.balance(day, "68.04").get(("68.04", FNS), D(0))
|
||||
if due > 0:
|
||||
lg.post(day, "68.04", FNS, "51", None, due, "уплата налога на прибыль")
|
||||
if month == 12:
|
||||
profit = -lg.balance(day, "99").get(("99", None), D(0))
|
||||
if profit > 0:
|
||||
lg.post(day, "99", None, "84", None, profit, "реформация баланса")
|
||||
elif profit < 0:
|
||||
lg.post(day, "84", None, "99", None, -profit, "реформация баланса")
|
||||
|
||||
|
||||
def months(ps: list[Period]) -> list[tuple[int, int]]:
|
||||
"""Месяцы книги: с января года первого периода до конца последнего периода."""
|
||||
end = book_end(ps)
|
||||
out = []
|
||||
y, mth = ps[0].year, 1
|
||||
while (y, mth) <= (end.year, end.month):
|
||||
out.append((y, mth))
|
||||
y, mth = (y + 1, 1) if mth == 12 else (y, mth + 1)
|
||||
return out
|
||||
|
||||
|
||||
def build_ledgers(
|
||||
cfg: Config,
|
||||
group: Group,
|
||||
ps: list[Period],
|
||||
plan: CounterpartyPlan,
|
||||
extra: Callable[[dict[str, Ledger], int, int], None] | None = None,
|
||||
) -> dict[str, Ledger]:
|
||||
"""Книги всех членов: входящие остатки + помесячно (внешние операции, `extra`, закрытие)."""
|
||||
ledgers = open_books(cfg, group, ps, plan)
|
||||
for year, month in months(ps):
|
||||
post_month(ledgers, cfg, group, plan, year, month)
|
||||
if extra is not None:
|
||||
extra(ledgers, year, month)
|
||||
close_month(ledgers, year, month)
|
||||
return ledgers
|
||||
@ -9,7 +9,7 @@ from pathlib import Path
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class An:
|
||||
cp: str | None
|
||||
contract: str | None = None
|
||||
@ -18,7 +18,7 @@ class An:
|
||||
inn_override: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Posting:
|
||||
day: date
|
||||
dt: str
|
||||
@ -37,12 +37,22 @@ class Ledger:
|
||||
self.company = company
|
||||
self.postings: list[Posting] = []
|
||||
self._cache: dict = {}
|
||||
# Индекс для быстрого сальдо «на конец»: накопленные итоги по (счёт, аналитика) и последний день проводок.
|
||||
# Закрытие месяца спрашивает сальдо на дату не раньше всех проводок — тогда достаточно пройти по ключам, а не по проводкам.
|
||||
self._tot: dict[Key, Decimal] = {}
|
||||
self._maxday: date | None = None
|
||||
self.meta: dict = {} # произвольные данные модели компании (используются economy)
|
||||
|
||||
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()
|
||||
kd, kc = (dt, dt_an), (ct, ct_an)
|
||||
self._tot[kd] = self._tot.get(kd, Decimal(0)) + amount
|
||||
self._tot[kc] = self._tot.get(kc, Decimal(0)) - amount
|
||||
if self._maxday is None or day > self._maxday:
|
||||
self._maxday = day
|
||||
|
||||
@staticmethod
|
||||
def _match(code: str, prefix: str | None) -> bool:
|
||||
@ -52,13 +62,18 @@ class Ledger:
|
||||
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
|
||||
if self._maxday is None or at >= self._maxday:
|
||||
for key, v in self._tot.items():
|
||||
if self._match(key[0], account_prefix):
|
||||
res[key] = v
|
||||
else:
|
||||
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])
|
||||
|
||||
|
||||
46
synthgen/periods.py
Normal file
46
synthgen/periods.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Отчётные периоды (спека §5.1): кварталы подряд, каждый — нарастающим итогом с начала года, как выгружает 1С."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
_LABELS = {1: "1 квартал {y} г.", 2: "1 полугодие {y} г.", 3: "9 месяцев {y} г.", 4: "{y} г."}
|
||||
_END = {1: (3, 31), 2: (6, 30), 3: (9, 30), 4: (12, 31)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Period:
|
||||
key: str # 2025-Q1, 2025-Q2, 2025-Q3, 2025-FY
|
||||
year: int
|
||||
quarter: int
|
||||
start: date # всегда 1 января (нарастающий итог)
|
||||
end: date
|
||||
label_1c: str
|
||||
|
||||
|
||||
def _make(year: int, quarter: int) -> Period:
|
||||
month, day = _END[quarter]
|
||||
key = f"{year}-FY" if quarter == 4 else f"{year}-Q{quarter}"
|
||||
return Period(key, year, quarter, date(year, 1, 1), date(year, month, day), _LABELS[quarter].format(y=year))
|
||||
|
||||
|
||||
def build_periods(start: str, n: int) -> list[Period]:
|
||||
"""`n` периодов подряд начиная с `start` (`ГГГГ-Qn`); четвёртый квартал — годовой период `ГГГГ-FY`."""
|
||||
year_s, q_s = start.split("-Q")
|
||||
year, quarter = int(year_s), int(q_s)
|
||||
out: list[Period] = []
|
||||
for _ in range(n):
|
||||
out.append(_make(year, quarter))
|
||||
quarter += 1
|
||||
if quarter == 5:
|
||||
year, quarter = year + 1, 1
|
||||
return out
|
||||
|
||||
|
||||
def book_start(ps: list[Period]) -> date:
|
||||
"""Дата входящих остатков: 31 декабря года перед первым периодом."""
|
||||
return date(ps[0].year - 1, 12, 31)
|
||||
|
||||
|
||||
def book_end(ps: list[Period]) -> date:
|
||||
"""Конец книги — конец последнего периода."""
|
||||
return ps[-1].end
|
||||
63
synthgen/tests/test_counterparties.py
Normal file
63
synthgen/tests/test_counterparties.py
Normal file
@ -0,0 +1,63 @@
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from synthgen import dictionary
|
||||
from synthgen.config import Config
|
||||
from synthgen.counterparties import WEIGHTS, plan
|
||||
from synthgen.group import build_group
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def orgs():
|
||||
return dictionary.load()
|
||||
|
||||
|
||||
def _mean_len(p):
|
||||
return sum(len(v) for v in p.by_file.values()) / len(p.by_file)
|
||||
|
||||
|
||||
def test_mean_matches(orgs):
|
||||
cfg = Config(group_size=10, periods=4, osv_counterparties_mean=60)
|
||||
p = plan(cfg, build_group(cfg, orgs))
|
||||
assert not p.capped
|
||||
assert len(p.by_file) == 10 * len(WEIGHTS)
|
||||
assert abs(_mean_len(p) - 60) <= 0.15 * 60
|
||||
|
||||
|
||||
def test_cap_and_flag(orgs):
|
||||
cfg = Config(group_size=40, periods=4, osv_counterparties_mean=150)
|
||||
g = build_group(cfg, orgs)
|
||||
p = plan(cfg, g)
|
||||
assert p.capped
|
||||
bank_inns = {b.inn for b in g.banks}
|
||||
for (code, acct), lst in p.by_file.items():
|
||||
assert len(lst) == len({o.inn for o in lst})
|
||||
if acct in ("66", "67"):
|
||||
assert len(lst) <= len(g.banks) and all(o.inn in bank_inns for o in lst)
|
||||
else:
|
||||
assert len(lst) <= len(g.externals)
|
||||
|
||||
|
||||
def test_sets_disjoint_from_group(orgs):
|
||||
cfg = Config(group_size=8, osv_counterparties_mean=100)
|
||||
g = build_group(cfg, orgs)
|
||||
p = plan(cfg, g)
|
||||
forbidden = {m.org.inn for m in g.members} | {b.inn for b in g.banks}
|
||||
for (code, acct), lst in p.by_file.items():
|
||||
if acct in ("58", "60", "62", "76"):
|
||||
assert not forbidden & {o.inn for o in lst}
|
||||
|
||||
|
||||
def test_zero_mean(orgs):
|
||||
cfg = Config(group_size=4, osv_counterparties_mean=0)
|
||||
p = plan(cfg, build_group(cfg, orgs))
|
||||
assert all(v == [] for v in p.by_file.values()) and not p.capped
|
||||
|
||||
|
||||
def test_plan_deterministic(orgs):
|
||||
cfg = Config(group_size=6)
|
||||
g = build_group(cfg, orgs)
|
||||
assert plan(cfg, g) == plan(cfg, g)
|
||||
other = plan(replace(cfg, seed=2), build_group(replace(cfg, seed=2), orgs))
|
||||
assert other != plan(cfg, g)
|
||||
120
synthgen/tests/test_economy.py
Normal file
120
synthgen/tests/test_economy.py
Normal file
@ -0,0 +1,120 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from synthgen import dictionary
|
||||
from synthgen.config import Config
|
||||
from synthgen.counterparties import plan as make_plan
|
||||
from synthgen.economy import build_ledgers, last_day, months
|
||||
from synthgen.group import build_group
|
||||
from synthgen.periods import book_start, build_periods
|
||||
|
||||
D = Decimal
|
||||
|
||||
|
||||
def _build(**kw):
|
||||
cfg = Config(group_size=kw.pop("group_size", 3), periods=kw.pop("periods", 4), osv_counterparties_mean=kw.pop("mean", 20), **kw)
|
||||
g = build_group(cfg, dictionary.load())
|
||||
ps = build_periods(cfg.start, cfg.periods)
|
||||
pl = make_plan(cfg, g)
|
||||
return cfg, g, ps, pl, build_ledgers(cfg, g, ps, pl)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def built():
|
||||
return _build()
|
||||
|
||||
|
||||
def test_books_balanced(built):
|
||||
cfg, g, ps, pl, books = built
|
||||
start = book_start(ps)
|
||||
for lg in books.values():
|
||||
for day in (start, date(2025, 3, 31), date(2025, 12, 31)):
|
||||
assert sum(lg.balance(day).values()) == 0
|
||||
assert lg.balance(day, "000") == {}
|
||||
assert not [p for p in lg.postings if "000" in (p.dt, p.ct) and p.day > start]
|
||||
|
||||
|
||||
def test_bs_identity(built):
|
||||
cfg, g, ps, pl, books = built
|
||||
for lg in books.values():
|
||||
for p in ps:
|
||||
day = p.end
|
||||
for acct in ("90", "91"):
|
||||
assert lg.balance(day, acct) == {}
|
||||
if p.quarter == 4:
|
||||
assert lg.balance(day, "99") == {}
|
||||
bal = lg.balance(day)
|
||||
assert sum(v for (a, _), v in bal.items() if a != "000") == 0
|
||||
# активы не уходят в минус: денежные средства на конец каждого месяца
|
||||
for y, m in months(ps):
|
||||
assert lg.balance(last_day(y, m), "51").get(("51", None), D(0)) >= 0
|
||||
|
||||
|
||||
def test_externals_used(built):
|
||||
cfg, g, ps, pl, books = built
|
||||
for (code, acct), lst in pl.by_file.items():
|
||||
if not lst or acct not in ("58", "60", "62", "66", "67", "76"):
|
||||
continue
|
||||
seen = set()
|
||||
for p in books[code].postings:
|
||||
for an in (p.dt_an, p.ct_an):
|
||||
if an is not None and an.cp:
|
||||
seen.add(an.cp)
|
||||
used = sum(1 for o in lst if o.inn in seen)
|
||||
assert used >= 0.8 * len(lst), (code, acct, used, len(lst))
|
||||
|
||||
|
||||
def test_pareto_shape(built):
|
||||
cfg, g, ps, pl, books = built
|
||||
tot = {code: sum(c for (a, _), (_, c) in lg.turnover(date(2025, 1, 1), date(2025, 12, 31), "62").items()) for code, lg in books.items()}
|
||||
biggest = max(tot, key=tot.get)
|
||||
by_cp = {}
|
||||
for (a, an), (dt, ct) in books[biggest].turnover(date(2025, 1, 1), date(2025, 12, 31), "62").items():
|
||||
if an is not None:
|
||||
by_cp[an.cp] = by_cp.get(an.cp, D(0)) + dt + ct
|
||||
vals = sorted(by_cp.values(), reverse=True)
|
||||
top = max(1, len(vals) // 10)
|
||||
assert sum(vals[:top]) >= D("0.5") * sum(vals)
|
||||
|
||||
|
||||
def test_signs_variety(built):
|
||||
cfg, g, ps, pl, books = built
|
||||
for acct in ("60", "62"):
|
||||
signs = set()
|
||||
for lg in books.values():
|
||||
for v in lg.balance(date(2025, 12, 31), acct).values():
|
||||
signs.add(v > 0)
|
||||
assert signs == {True, False}, acct
|
||||
|
||||
|
||||
def test_year_rollover():
|
||||
cfg, g, ps, pl, books = _build(start="2025-Q3")
|
||||
dec = date(2025, 12, 31)
|
||||
for lg in books.values():
|
||||
assert lg.balance(dec, "90") == {} and lg.balance(dec, "91") == {} and lg.balance(dec, "99") == {}
|
||||
assert lg.balance(dec, "84") != lg.balance(date(2024, 12, 31), "84")
|
||||
t = lg.turnover(date(2026, 1, 1), date(2026, 1, 1), "90")
|
||||
assert t == {} # 2026 начинается с нуля; обороты 90 в январе — не 1 января
|
||||
jan = lg.turnover(date(2026, 1, 1), date(2026, 1, 31), "90")
|
||||
assert any(dt or ct for dt, ct in jan.values())
|
||||
assert max(p.day for lg in books.values() for p in lg.postings) == date(2026, 6, 30)
|
||||
|
||||
|
||||
def test_extra_hook_called_before_close():
|
||||
cfg = Config(group_size=2, periods=1, osv_counterparties_mean=5)
|
||||
g = build_group(cfg, dictionary.load())
|
||||
ps = build_periods(cfg.start, 1)
|
||||
seen = []
|
||||
def hook(ledgers, year, month):
|
||||
seen.append((year, month, len(ledgers["C01"].postings)))
|
||||
build_ledgers(cfg, g, ps, make_plan(cfg, g), extra=hook)
|
||||
assert [(y, m) for y, m, _ in seen] == [(2025, 1), (2025, 2), (2025, 3)]
|
||||
|
||||
|
||||
def test_deterministic_and_zero_mean():
|
||||
a = _build(mean=0)[4]
|
||||
b = _build(mean=0)[4]
|
||||
assert [p for p in a["C01"].postings] == [p for p in b["C01"].postings]
|
||||
assert a["C01"].postings
|
||||
23
synthgen/tests/test_periods.py
Normal file
23
synthgen/tests/test_periods.py
Normal file
@ -0,0 +1,23 @@
|
||||
from datetime import date
|
||||
|
||||
from synthgen.periods import book_end, book_start, build_periods
|
||||
|
||||
|
||||
def test_build_periods_simple():
|
||||
ps = build_periods("2025-Q1", 4)
|
||||
assert [p.key for p in ps] == ["2025-Q1", "2025-Q2", "2025-Q3", "2025-FY"]
|
||||
assert [p.label_1c for p in ps] == ["1 квартал 2025 г.", "1 полугодие 2025 г.", "9 месяцев 2025 г.", "2025 г."]
|
||||
assert all(p.start == date(2025, 1, 1) for p in ps)
|
||||
assert [p.end for p in ps] == [date(2025, 3, 31), date(2025, 6, 30), date(2025, 9, 30), date(2025, 12, 31)]
|
||||
|
||||
|
||||
def test_build_periods_rollover():
|
||||
ps = build_periods("2025-Q3", 4)
|
||||
assert [p.key for p in ps] == ["2025-Q3", "2025-FY", "2026-Q1", "2026-Q2"]
|
||||
assert ps[2].start == date(2026, 1, 1) and ps[2].year == 2026 and ps[2].quarter == 1
|
||||
assert book_end(ps) == date(2026, 6, 30)
|
||||
|
||||
|
||||
def test_book_start():
|
||||
assert book_start(build_periods("2025-Q1", 4)) == date(2024, 12, 31)
|
||||
assert book_start(build_periods("2025-Q3", 4)) == date(2024, 12, 31)
|
||||
Loading…
x
Reference in New Issue
Block a user