#!/usr/bin/env python3
"""Сквозная аналитика синтетической компании «Линия».
Запуск из корня проекта: python3 output/build_dashboard.py
Читает только семь файлов из input/, проверяет их, считает один отчёт и
из него записывает в output/: report.json, channel-report.csv, dashboard.html,
interpretation.md, source-hashes.json. Только стандартная библиотека Python 3.
Денежная арифметика — только int (целые рубли) и целочисленное деление.
Внешних запросов нет; входные файлы не изменяются; времени запуска в
артефактах нет. Файл checks.md ведётся отдельно (запись реальных запусков).
"""
import csv
import hashlib
import io
import html
import json
import sys
from datetime import datetime
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
INPUT = ROOT / "input"
OUTPUT = ROOT / "output"
INPUT_FILES = [
"channel-costs.csv",
"company.json",
"form-events.csv",
"leads.csv",
"measurement-contract.json",
"orders.csv",
"payment-events.csv",
]
UNATTRIBUTED = "unattributed"
SCHEMA_VERSION = "1.0.0"
METHOD_LABEL = ("первый сохранённый источник лида; денежный отчёт по "
"фиксированной сентябрьской когорте")
class CalcError(Exception):
"""Неподдерживаемые или противоречивые данные: расчёт останавливается."""
def __init__(self, problems):
self.problems = list(problems)
super().__init__("; ".join(self.problems))
# ----------------------------------------------------------------- чтение ---
def put(path, text):
"""UTF-8, LF, без зависимости от платформы (совместимо с Python 3.9)."""
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def read_json(name):
try:
obj = json.loads((INPUT / name).read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as exc:
raise CalcError([f"{name}: файл не читается как JSON ({exc})"])
if not isinstance(obj, dict):
raise CalcError([f"{name}: ожидался JSON-объект"])
return obj
def nonempty_str(obj, key, where, problems):
v = obj.get(key)
if not isinstance(v, str) or not v.strip():
problems.append(f"{where}: поле {key} должно быть непустой строкой")
return None
return v
def aware_ts(obj, key, where, problems):
v = nonempty_str(obj, key, where, problems)
if v is None:
return None
try:
dt = datetime.fromisoformat(v)
except ValueError:
problems.append(f"{where}: {key}={v!r} — не ISO-время")
return None
if dt.tzinfo is None or dt.utcoffset() is None:
problems.append(f"{where}: {key}={v!r} — нет часового пояса")
return None
return dt
def validate_contract(contract, company, problems):
"""Полная проверка JSON-входов до чтения CSV и до записи любых файлов.
Возвращает (start, end) периода."""
where = "measurement-contract.json"
for nm, obj in ((where, contract), ("company.json", company)):
if obj.get("is_synthetic") is not True:
problems.append(f"{nm}: is_synthetic должен быть true")
start = aware_ts(contract, "period_start", where, problems)
end = aware_ts(contract, "period_end_exclusive", where, problems)
if start and end and not start < end:
problems.append(f"{where}: period_start ({contract['period_start']}) должен быть "
f"строго раньше period_end_exclusive ({contract['period_end_exclusive']}) — "
"иначе когорта пуста")
nonempty_str(contract, "timezone", where, problems)
if contract.get("currency") != "RUB":
problems.append(f"{where}: поддерживается только currency=RUB")
for key in ("attribution", "unknown_policy"):
nonempty_str(contract, key, where, problems)
lim = contract.get("limitations")
if (not isinstance(lim, list) or not lim
or not all(isinstance(x, str) and x.strip() for x in lim)):
problems.append(f"{where}: limitations должно быть непустым списком непустых строк")
nonempty_str(company, "name", "company.json", problems)
return start, end
COLUMNS = {
"channel-costs.csv": ["channel", "paid_spend_rub", "other_cost_rub", "is_synthetic"],
"leads.csv": ["lead_id", "created_at", "channel", "qualified", "is_test", "is_synthetic"],
"form-events.csv": ["event_id", "lead_id", "occurred_at", "is_test", "is_synthetic"],
"orders.csv": ["order_id", "lead_id", "customer_id", "created_at",
"amount_due_rub", "is_test", "is_synthetic"],
"payment-events.csv": ["transaction_id", "order_id", "occurred_at", "kind",
"status", "amount_rub", "is_test", "is_synthetic"],
}
def read_csv_rows(name, problems):
"""Возвращает список (номер записи, dict). Пустые строки пропускаются."""
text = (INPUT / name).read_bytes().decode("utf-8-sig")
lines = [ln for ln in text.splitlines() if ln.strip() != ""]
if not lines:
problems.append(f"{name}: файл пуст")
return []
reader = csv.reader(lines)
header = [h.strip() for h in next(reader)]
if sorted(header) != sorted(COLUMNS[name]) or len(set(header)) != len(header):
problems.append(f"{name}: схема не совпадает; ожидались колонки "
f"{COLUMNS[name]}, найдены {header}")
return []
rows = []
for n, rec in enumerate(reader, start=1):
if len(rec) != len(header):
problems.append(f"{name}, запись {n}: число полей {len(rec)} вместо {len(header)}")
continue
rows.append((n, {k: v.strip() for k, v in zip(header, rec)}))
return rows
def parse_flag(name, n, row, field, problems):
v = row[field]
if v == "true":
return True
if v == "false":
return False
problems.append(f"{name}, запись {n}: поле {field} должно быть строго true/false, получено {v!r}")
return None
def parse_money(name, n, row, field, problems):
v = row[field]
if not v.isascii() or not v.isdigit():
problems.append(f"{name}, запись {n}: {field}={v!r} — нужна неотрицательная "
"целая сумма в рублях (дроби и знаки не принимаются)")
return None
return int(v)
def parse_ts(name, n, row, field, problems):
v = row[field]
try:
dt = datetime.fromisoformat(v)
except ValueError:
problems.append(f"{name}, запись {n}: {field}={v!r} — не ISO-время")
return None
if dt.tzinfo is None or dt.utcoffset() is None:
problems.append(f"{name}, запись {n}: {field}={v!r} — нет часового пояса")
return None
return dt
def need_id(name, n, row, field, problems):
v = row[field]
if v == "":
problems.append(f"{name}, запись {n}: пустой обязательный идентификатор {field}")
return None
return v
def dedupe(name, rows, key, problems):
"""Идентичный повтор — один раз; конфликт по одному ID — ошибка."""
seen = {}
replays = 0
for n, row in rows:
k = row[key]
if k == "":
continue # уже диагностировано как пустой ID
if k in seen:
if seen[k][1] == row:
replays += 1
else:
problems.append(f"{name}, запись {n}: конфликтующие строки с {key}={k!r} "
f"(первая запись {seen[k][0]})")
else:
seen[k] = (n, row)
unique = [(n, r) for n, r in seen.values()]
return unique, replays
def load_sources():
problems = []
contract = read_json("measurement-contract.json")
company = read_json("company.json")
start, end = validate_contract(contract, company, problems)
if problems:
raise CalcError(problems)
raw = {}
for name in COLUMNS:
raw[name] = read_csv_rows(name, problems)
if problems:
raise CalcError(problems)
counts = {}
typed = {}
# --- channel-costs
nm = "channel-costs.csv"
rows = raw[nm]
uniq, rep = dedupe(nm, rows, "channel", problems)
costs = {}
order_of_channels = []
for n, r in uniq:
syn = parse_flag(nm, n, r, "is_synthetic", problems)
if syn is False:
problems.append(f"{nm}, запись {n}: принимаются только синтетические строки")
paid = parse_money(nm, n, r, "paid_spend_rub", problems)
other = parse_money(nm, n, r, "other_cost_rub", problems)
ch = need_id(nm, n, r, "channel", problems)
if ch == UNATTRIBUTED:
problems.append(f"{nm}, запись {n}: имя канала {UNATTRIBUTED!r} зарезервировано")
if ch and paid is not None and other is not None:
costs[ch] = (paid, other)
order_of_channels.append(ch)
for n, r in rows:
if r["channel"] == "":
problems.append(f"{nm}, запись {n}: пустой канал расхода")
counts[nm] = {"raw": len(rows), "unique": len(uniq), "replays": rep}
# --- leads
nm = "leads.csv"
rows = raw[nm]
uniq, rep = dedupe(nm, rows, "lead_id", problems)
leads = {}
for n, r in uniq:
lid = need_id(nm, n, r, "lead_id", problems)
ts = parse_ts(nm, n, r, "created_at", problems)
q = parse_flag(nm, n, r, "qualified", problems)
t = parse_flag(nm, n, r, "is_test", problems)
s = parse_flag(nm, n, r, "is_synthetic", problems)
if s is False:
problems.append(f"{nm}, запись {n}: принимаются только синтетические строки")
if lid and ts and None not in (q, t):
leads[lid] = {"ts": ts, "channel": r["channel"] or UNATTRIBUTED,
"qualified": q, "test": t}
for n, r in rows:
if r["lead_id"] == "":
problems.append(f"{nm}, запись {n}: пустой обязательный идентификатор lead_id")
counts[nm] = {"raw": len(rows), "unique": len(uniq), "replays": rep}
# --- orders
nm = "orders.csv"
rows = raw[nm]
uniq, rep = dedupe(nm, rows, "order_id", problems)
orders = {}
for n, r in uniq:
oid = need_id(nm, n, r, "order_id", problems)
lid = need_id(nm, n, r, "lead_id", problems)
cid = need_id(nm, n, r, "customer_id", problems)
ts = parse_ts(nm, n, r, "created_at", problems)
amt = parse_money(nm, n, r, "amount_due_rub", problems)
t = parse_flag(nm, n, r, "is_test", problems)
s = parse_flag(nm, n, r, "is_synthetic", problems)
if s is False:
problems.append(f"{nm}, запись {n}: принимаются только синтетические строки")
if amt is not None and amt <= 0:
problems.append(f"{nm}, запись {n}: amount_due_rub должен быть положительным")
if oid and lid and cid and ts and amt and t is not None:
orders[oid] = {"lead": lid, "customer": cid, "ts": ts, "due": amt, "test": t}
for n, r in rows:
if r["order_id"] == "":
problems.append(f"{nm}, запись {n}: пустой обязательный идентификатор order_id")
counts[nm] = {"raw": len(rows), "unique": len(uniq), "replays": rep}
# --- form-events
nm = "form-events.csv"
rows = raw[nm]
uniq, rep = dedupe(nm, rows, "event_id", problems)
events = {}
for n, r in uniq:
eid = need_id(nm, n, r, "event_id", problems)
lid = need_id(nm, n, r, "lead_id", problems)
ts = parse_ts(nm, n, r, "occurred_at", problems)
t = parse_flag(nm, n, r, "is_test", problems)
s = parse_flag(nm, n, r, "is_synthetic", problems)
if s is False:
problems.append(f"{nm}, запись {n}: принимаются только синтетические строки")
if eid and lid and ts and t is not None:
events[eid] = {"lead": lid, "ts": ts, "test": t}
for n, r in rows:
if r["event_id"] == "":
problems.append(f"{nm}, запись {n}: пустой обязательный идентификатор event_id")
counts[nm] = {"raw": len(rows), "unique": len(uniq), "replays": rep}
# --- payment-events
nm = "payment-events.csv"
rows = raw[nm]
uniq, rep = dedupe(nm, rows, "transaction_id", problems)
txs = {}
for n, r in uniq:
tid = need_id(nm, n, r, "transaction_id", problems)
oid = need_id(nm, n, r, "order_id", problems)
ts = parse_ts(nm, n, r, "occurred_at", problems)
amt = parse_money(nm, n, r, "amount_rub", problems)
t = parse_flag(nm, n, r, "is_test", problems)
s = parse_flag(nm, n, r, "is_synthetic", problems)
if s is False:
problems.append(f"{nm}, запись {n}: принимаются только синтетические строки")
kind, status = r["kind"], r["status"]
if kind not in ("payment", "refund"):
problems.append(f"{nm}, запись {n}: неподдерживаемый kind={kind!r}")
if status not in ("succeeded", "pending", "failed"):
problems.append(f"{nm}, запись {n}: неподдерживаемый status={status!r}")
if status == "succeeded" and amt is not None and amt <= 0:
problems.append(f"{nm}, запись {n}: сумма успешной операции должна быть положительной")
if tid and oid and ts and amt is not None and t is not None:
txs[tid] = {"order": oid, "ts": ts, "kind": kind, "status": status,
"amount": amt, "test": t}
for n, r in rows:
if r["transaction_id"] == "":
problems.append(f"{nm}, запись {n}: пустой обязательный идентификатор transaction_id")
counts[nm] = {"raw": len(rows), "unique": len(uniq), "replays": rep}
if problems:
raise CalcError(problems)
return {"contract": contract, "company": company, "start": start, "end": end,
"costs": costs, "cost_order": order_of_channels, "leads": leads,
"orders": orders, "events": events, "txs": txs, "counts": counts}
# ---------------------------------------------------------------- расчёт ---
def ratio(num, den, places):
"""{numerator, denominator, value}; value=None при неизвестном числителе или den=0.
Округление half-up целочисленно; float не используется."""
if num is None or not den:
return {"numerator": num, "denominator": den, "value": None}
scale = 10 ** places
q = (num * scale * 2 + den) // (2 * den)
return {"numerator": num, "denominator": den, "value": Decimal(q).scaleb(-places)}
def classify(src):
"""Применяет правила тестов, периода и связей; проверяет хронологию и деньги."""
problems = []
start, end = src["start"], src["end"]
leads, orders, events, txs = src["leads"], src["orders"], src["events"], src["txs"]
excl = {k: {} for k in ("leads.csv", "form-events.csv", "orders.csv", "payment-events.csv")}
def drop(file, reason):
excl[file][reason] = excl[file].get(reason, 0) + 1
def in_period(ts):
return start <= ts < end
# лиды
elig_leads = {}
for lid, l in sorted(leads.items()):
if l["test"]:
drop("leads.csv", "test_row")
elif not in_period(l["ts"]):
drop("leads.csv", "outside_fixed_period")
else:
elig_leads[lid] = l
# события формы
elig_events = {}
for eid, e in sorted(events.items()):
if e["test"]:
drop("form-events.csv", "test_row")
continue
if e["lead"] not in leads:
problems.append(f"form-events.csv: {eid} ссылается на неизвестный lead_id {e['lead']}")
continue
if not in_period(e["ts"]):
drop("form-events.csv", "outside_fixed_period")
elif e["lead"] not in elig_leads:
drop("form-events.csv", "linked_lead_not_eligible")
elif e["ts"] < elig_leads[e["lead"]]["ts"]:
problems.append(f"form-events.csv: {eid} раньше своего лида {e['lead']}")
else:
elig_events[eid] = e
# заказы
elig_orders = {}
for oid, o in sorted(orders.items()):
if o["test"]:
drop("orders.csv", "test_row")
continue
if o["lead"] not in leads:
problems.append(f"orders.csv: {oid} ссылается на неизвестный lead_id {o['lead']}")
continue
if not in_period(o["ts"]):
drop("orders.csv", "outside_fixed_period")
elif o["lead"] not in elig_leads:
drop("orders.csv", "linked_lead_not_eligible")
elif o["ts"] < elig_leads[o["lead"]]["ts"]:
problems.append(f"orders.csv: {oid} создан раньше своего лида {o['lead']}")
else:
elig_orders[oid] = o
# операции
payments = {} # order_id -> (tx_id, tx)
refunds = {} # order_id -> [(tx_id, tx)]
for tid, t in sorted(txs.items()):
if t["test"]:
drop("payment-events.csv", "test_row")
continue
if t["order"] not in orders:
problems.append(f"payment-events.csv: {tid} ссылается на неизвестный order_id {t['order']}")
continue
if not in_period(t["ts"]):
drop("payment-events.csv", "outside_fixed_period")
continue
if t["order"] not in elig_orders:
drop("payment-events.csv", "linked_order_not_eligible")
continue
if t["status"] != "succeeded":
drop("payment-events.csv", f"{t['kind']}_status_{t['status']}_not_cash")
continue
o = elig_orders[t["order"]]
if t["ts"] < o["ts"]:
problems.append(f"payment-events.csv: {tid} раньше заказа {t['order']}")
continue
if t["kind"] == "payment":
if t["amount"] != o["due"]:
problems.append(f"payment-events.csv: платёж {tid} на {t['amount']} RUB не равен "
f"сумме заказа {t['order']} ({o['due']} RUB): неподдерживаемая оплата")
continue
if t["order"] in payments:
problems.append(f"payment-events.csv: заказ {t['order']} имеет несколько разных "
f"успешных платежей ({payments[t['order']][0]}, {tid}): ручная сверка")
continue
payments[t["order"]] = (tid, t)
else:
refunds.setdefault(t["order"], []).append((tid, t))
for oid, lst in sorted(refunds.items()):
if oid not in payments:
problems.append(f"payment-events.csv: возврат {lst[0][0]} по заказу {oid} без "
"поддерживаемой успешной оплаты")
continue
pay_id, pay = payments[oid]
for rid, r in lst:
if r["ts"] <= pay["ts"]:
problems.append(f"payment-events.csv: возврат {rid} не позже оплаты {pay_id}")
total = sum(r["amount"] for _, r in lst)
if total > pay["amount"]:
problems.append(f"payment-events.csv: возвраты по {oid} ({total} RUB) превышают "
f"оплату {pay_id} ({pay['amount']} RUB)")
# клиенты первой оплаты
owner = {}
for oid in sorted(payments):
c = elig_orders[oid]["customer"]
if c in owner:
problems.append(f"orders.csv: customer_id {c} платит по разным заказам "
f"({owner[c]}, {oid}): вне поддерживаемой атрибуции, ручная сверка")
owner[c] = oid
if problems:
raise CalcError(problems)
return {"elig_leads": elig_leads, "elig_events": elig_events, "elig_orders": elig_orders,
"payments": payments, "refunds": refunds, "excluded": excl}
def build_section(channel, cls, src, lead_filter, cost):
"""cost: None (неизвестно) или (paid, other). lead_filter(lead_id)->bool."""
lids = sorted(l for l in cls["elig_leads"] if lead_filter(l))
lset = set(lids)
qids = [l for l in lids if cls["elig_leads"][l]["qualified"]]
eids = sorted(e for e, ev in cls["elig_events"].items() if ev["lead"] in lset)
oids = sorted(o for o, od in cls["elig_orders"].items() if od["lead"] in lset)
pids = [o for o in oids if o in cls["payments"]]
cust = sorted({cls["elig_orders"][o]["customer"] for o in pids})
gross = sum(cls["payments"][o][1]["amount"] for o in pids)
ref = sum(r["amount"] for o in pids for _, r in cls["refunds"].get(o, []))
net = gross - ref
if cost is None:
paid = other = total_cost = None
else:
paid, other = cost
total_cost = paid + other
return {
"channel": channel,
"lead_ids": lids, "qualified_lead_ids": qids, "eligible_order_ids": oids,
"paid_order_ids": pids, "customer_ids": cust, "form_event_ids": eids,
"leads": len(lids), "qualified": len(qids), "eligible_orders": len(oids),
"paid_orders": len(pids), "paid_customers": len(cust), "form_events": len(eids),
"paid_spend_rub": paid, "other_cost_rub": other, "total_cost_rub": total_cost,
"cost_coverage": None,
"gross_revenue_rub": gross, "refunds_rub": ref, "net_revenue_rub": net,
"net_revenue_minus_acquisition_rub": None if total_cost is None else net - total_cost,
"profit_rub": None,
"cpl": ratio(total_cost, len(lids), 2),
"cpql": ratio(total_cost, len(qids), 2),
"cac": ratio(total_cost, len(cust), 2),
"qualified_rate": ratio(len(qids), len(lids), 4),
"paid_rate": ratio(len(cust), len(lids), 4),
}
def compute(src):
cls = classify(src)
contract = src["contract"]
costs = src["costs"]
diagnostics = []
channels_in_leads = []
for lid in sorted(cls["elig_leads"]):
ch = cls["elig_leads"][lid]["channel"]
if ch not in channels_in_leads:
channels_in_leads.append(ch)
names = list(src["cost_order"])
for ch in sorted(channels_in_leads):
if ch not in names and ch != UNATTRIBUTED:
names.append(ch)
names.append(UNATTRIBUTED)
pool_paid = sum(p for p, _ in costs.values())
pool_other = sum(o for _, o in costs.values())
pool_total = pool_paid + pool_other
total = build_section("all", cls, src, lambda l: True, (pool_paid, pool_other))
total["cost_coverage"] = "full_pool"
sections = []
for ch in names:
lf = (lambda l, ch=ch: cls["elig_leads"][l]["channel"] == ch)
if ch == UNATTRIBUTED:
sec = build_section(ch, cls, src, lf, None)
sec["cost_coverage"] = "unknown_allocation"
elif ch in costs:
sec = build_section(ch, cls, src, lf, costs[ch])
sec["cost_coverage"] = "known"
else:
sec = build_section(ch, cls, src, lf, None)
sec["cost_coverage"] = "no_cost_entry"
diagnostics.append(f"Канал {ch} есть у лидов, но отсутствует в channel-costs.csv: "
"стоимость канала неизвестна (null), а не нулевая.")
sections.append(sec)
# покрытие атрибуции оплаченных заказов
known_paid = sum(s["paid_orders"] for s in sections if s["channel"] != UNATTRIBUTED)
coverage = ratio(known_paid, total["paid_orders"], 4)
def s_sum(field):
return sum(s[field] for s in sections)
known_cost_sum = sum(s["total_cost_rub"] for s in sections if s["total_cost_rub"] is not None)
recon_flags = {
"channel_leads_sum_equals_total": s_sum("leads") == total["leads"],
"channel_qualified_sum_equals_total": s_sum("qualified") == total["qualified"],
"channel_form_events_sum_equals_total": s_sum("form_events") == total["form_events"],
"channel_eligible_orders_sum_equals_total": s_sum("eligible_orders") == total["eligible_orders"],
"channel_paid_orders_sum_equals_total": s_sum("paid_orders") == total["paid_orders"],
"channel_paid_customers_sum_equals_total": s_sum("paid_customers") == total["paid_customers"],
"channel_net_revenue_sum_equals_total": s_sum("net_revenue_rub") == total["net_revenue_rub"],
"channel_lead_ids_partition_total": sorted(i for s in sections for i in s["lead_ids"]) == total["lead_ids"],
"known_channel_cost_sum_equals_global_pool": known_cost_sum == pool_total,
}
if not all(recon_flags.values()):
raise CalcError(["внутренняя сверка каналов с общим срезом не сошлась: "
+ ", ".join(k for k, v in recon_flags.items() if not v)])
files = {}
for name, c in src["counts"].items():
files[name] = {"raw_rows": c["raw"], "deduplicated_rows": c["unique"],
"identical_replays": c["replays"]}
for name, ex in cls["excluded"].items():
files[name]["excluded_rows"] = sum(ex.values())
files[name]["excluded_reasons"] = dict(sorted(ex.items()))
files[name]["eligible_rows"] = files[name]["deduplicated_rows"] - files[name]["excluded_rows"]
files["channel-costs.csv"]["excluded_rows"] = 0
files["channel-costs.csv"]["excluded_reasons"] = {}
files["channel-costs.csv"]["eligible_rows"] = files["channel-costs.csv"]["deduplicated_rows"]
report = {
"schema_version": SCHEMA_VERSION,
"data_status": "synthetic_draft_only",
"company": src["company"]["name"],
"period": {"start": contract["period_start"],
"end_exclusive": contract["period_end_exclusive"],
"timezone": contract["timezone"]},
"currency": contract["currency"],
"method": {
"label": METHOD_LABEL,
"attribution": contract["attribution"],
"money": "целые RUB, точная целочисленная арифметика; кассовый взгляд "
"(успешные оплаты минус успешные возвраты), не бухгалтерская выручка",
"unknown_policy": contract["unknown_policy"],
"profit": "неизвестно: нет себестоимости, налогов и полного P&L",
"net_revenue_minus_acquisition_label":
"Чистая выручка минус затраты на привлечение (не прибыль)",
},
"total": total,
"channels": sections,
"attribution_coverage_paid_orders": coverage,
"reconciliation": {
"files": files,
"transaction_replays": src["counts"]["payment-events.csv"]["replays"],
"flags": recon_flags,
"cost_pool": {
"paid_spend_rub": pool_paid, "other_cost_rub": pool_other,
"total_cost_rub": pool_total,
"known_channel_cost_sum_rub": known_cost_sum,
"unattributed_cost_rub": None,
"explanation": ("Общий срез использует весь пул расходов из channel-costs.csv. "
"Стоимость неопределённого источника (unattributed) неизвестна: "
"это null, не ноль и не добавка к пулу. Суммы каналов с известной "
"стоимостью равны пулу, поскольку все строки расходов относятся к "
"именованным каналам; это не доказывает нулевую стоимость unattributed."),
},
"diagnostics": diagnostics,
},
"limitations": contract["limitations"],
}
return report
# ------------------------------------------------------------- вывод/CSV ---
def jsonable(o):
if isinstance(o, Decimal):
return float(o) # только сериализация готового значения, не арифметика
if isinstance(o, dict):
return {k: jsonable(v) for k, v in o.items()}
if isinstance(o, list):
return [jsonable(v) for v in o]
return o
def dec_text(r):
return "null" if r["value"] is None else format(r["value"], "f")
def cell(v):
return "null" if v is None else str(v)
def render_csv(report):
cols = ["channel", "leads", "qualified", "eligible_orders", "paid_orders", "paid_customers",
"form_events", "paid_spend_rub", "other_cost_rub", "total_cost_rub", "cost_coverage",
"gross_revenue_rub", "refunds_rub", "net_revenue_rub",
"net_revenue_minus_acquisition_rub", "profit_rub", "cpl_rub", "cpql_rub", "cac_rub"]
buf = io.StringIO(newline="")
w = csv.writer(buf, lineterminator="\n")
w.writerow(cols)
for s in [report["total"]] + report["channels"]:
row = []
for c in cols:
if c in ("cpl_rub", "cpql_rub", "cac_rub"):
row.append(dec_text(s[c[:-4]]))
else:
row.append(cell(s[c]))
w.writerow(row)
return buf.getvalue()
def sha_info(files):
out = []
for name in files:
data = (INPUT / name).read_bytes()
out.append({"path": f"input/{name}", "bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest()})
return {"algorithm": "sha256", "files": out}
def rub(n):
return "неизвестно" if n is None else f"{n:,}".replace(",", "\u00a0") + "\u00a0RUB"
def rtext(r, money=True):
if r["value"] is None:
return "нет данных"
if money:
return f"{r['value']:,.2f}".replace(",", "\u00a0") + "\u00a0RUB"
return f"{(r['value'] * 100):.1f}%"
def render_interpretation(report):
t = report["total"]
ch = report["channels"]
rec = report["reconciliation"]
un = [s for s in ch if s["channel"] == UNATTRIBUTED][0]
lines = []
a = lines.append
a("# Интерпретация: сквозная аналитика «Линия» (синтетический черновик)")
a("")
a(f"Период: {report['period']['start']} — {report['period']['end_exclusive']} "
f"(не включая конец), {report['period']['timezone']}; валюта {report['currency']}. "
f"Метод: {report['method']['label']}. Все данные синтетические, это учебный локальный черновик.")
a("")
a("## Что показывают данные")
a("")
a(f"- События формы: {t['form_events']}; уникальные лиды: {t['leads']}; "
f"квалифицированные: {t['qualified']}. Это разные единицы: лишнее событие формы "
"не создаёт нового лида.")
a(f"- Созданные заказы: {t['eligible_orders']}; оплаченные заказы: {t['paid_orders']}; "
f"впервые оплатившие компании (синтетический фикстурный смысл): {t['paid_customers']}.")
a(f"- Весь пул затрат на привлечение: {rub(t['total_cost_rub'])} "
f"(платные {rub(t['paid_spend_rub'])} + прочие {rub(t['other_cost_rub'])}).")
a(f"- Кассовый взгляд: оплаты {rub(t['gross_revenue_rub'])}, возвраты {rub(t['refunds_rub'])}, "
f"чистая выручка {rub(t['net_revenue_rub'])}.")
a(f"- Чистая выручка минус затраты на привлечение: {rub(t['net_revenue_minus_acquisition_rub'])}. "
"Это только указанная разность, **не прибыль**: себестоимости, налогов и полного P&L нет, "
"прибыль неизвестна.")
a(f"- CPL {rtext(t['cpl'])} ({t['cpl']['numerator']} / {t['cpl']['denominator']}); "
f"CPQL {rtext(t['cpql'])} ({t['cpql']['numerator']} / {t['cpql']['denominator']}); "
f"CAC {rtext(t['cac'])} ({t['cac']['numerator']} / {t['cac']['denominator']}). "
f"Доля квалифицированных {rtext(t['qualified_rate'], False)}; доля оплативших компаний "
f"{rtext(t['paid_rate'], False)}. Общие показатели считаются из общих числителя и "
"знаменателя, а не средним по каналам.")
cov = report["attribution_coverage_paid_orders"]
a(f"- Покрытие атрибуции оплаченных заказов: {cov['numerator']} / {cov['denominator']} "
f"= {rtext(cov, False)}.")
a("")
a("## По каналам")
a("")
a("| Канал | Лиды | Квал. | Оплач. заказы | Затраты | CPL | CAC | Чистая выручка |")
a("|---|---:|---:|---:|---:|---:|---:|---:|")
for s in ch:
a(f"| {s['channel']} | {s['leads']} | {s['qualified']} | {s['paid_orders']} | "
f"{rub(s['total_cost_rub'])} | {rtext(s['cpl'])} | {rtext(s['cac'])} | "
f"{rub(s['net_revenue_rub'])} |")
a("")
a(f"Канал `{UNATTRIBUTED}` (лиды: {', '.join(un['lead_ids']) or 'нет'}) не получает стоимость: "
"его затраты неизвестны (null), поэтому CPL, CPQL, CAC и разность с затратами для него "
"«нет данных/неизвестно». Это не бесплатный канал, и его лиды не отнесены к другим каналам.")
a("")
a("## Сверка")
a("")
a(f"- Повторов транзакций, учтённых один раз: {rec['transaction_replays']}.")
for name, f in rec["files"].items():
reasons = ", ".join(f"{k}: {v}" for k, v in f["excluded_reasons"].items()) or "нет"
a(f"- `{name}`: строк {f['raw_rows']}, после дедупликации {f['deduplicated_rows']}, "
f"исключено {f['excluded_rows']} ({reasons}), в расчёте {f['eligible_rows']}.")
a(f"- Все булевы проверки сверки каналов с общим срезом: "
f"{'истинны' if all(rec['flags'].values()) else 'НЕ все истинны'}.")
a(f"- Известные затраты каналов суммарно {rub(rec['cost_pool']['known_channel_cost_sum_rub'])} "
f"при пуле {rub(rec['cost_pool']['total_cost_rub'])}. Распределение пула на `{UNATTRIBUTED}` "
"в данных не задано.")
a("")
a("## Ограничения")
a("")
for l in report["limitations"]:
a(f"- {l}")
a("- Малая учебная выборка: различия между каналами не доказывают причинный эффект и не дают "
"оснований для решений о реальном рекламном бюджете или оценки коммерческой отдачи.")
a("- Атрибуция: только первый сохранённый источник; это не многоканальная атрибуция.")
a("- Денежный отчёт — кассовый для фиксированной сентябрьской когорты, не бухгалтерская выручка.")
a("")
a("## Следующий проверяемый шаг")
a("")
a(f"Владельцу синтетического фикстура задать явное правило происхождения лидов канала "
f"`{UNATTRIBUTED}` ({', '.join(un['lead_ids']) or 'нет'}) и правило распределения пула затрат. "
"Проверка: после изменения фикстура (в отдельной копии проекта) повторно запустить "
"`python3 output/build_dashboard.py` и убедиться, что сумма затрат каналов равна пулу "
f"{rub(rec['cost_pool']['total_cost_rub'])}, а флаги сверки в `report.json` остались истинными. "
"Внешних операций это не требует.")
a("")
return "\n".join(lines)
# ---------------------------------------------------------------- HTML ---
HTML = r"""
Синтетический черновик: учебные данные, не выгрузка Метрики, CRM или банка.
Период: __PERIOD__Валюта: RUBЧасовой пояс: __TZ__
Метод: __METHOD__.
Ключевые показатели текущего среза
Диаграммы текущего среза
Воронка (штуки)
События формы, лиды, квалификации, заказы и оплаты — разные единицы.
Затраты на привлечение и чистая выручка (RUB)
Штриховка — стоимость неизвестна; это не нулевая стоимость.
Таблица каналов
Широкую таблицу можно прокручивать внутри рамки.
Канал
Лиды
Квалиф.
События формы
Созданные заказы
Оплаченные заказы
Впервые оплатившие
Затраты, RUB
Оплаты, RUB
Возвраты, RUB
Чистая выручка, RUB
Чистая выручка минус затраты (не прибыль)
CPL
CPQL
CAC
Прибыль
Определения
__DEFS__
Источники
__SOURCES__
Расчёт выполнен скриптом output/build_dashboard.py; данные встроены в эту страницу, внешних запросов нет.
Ограничения
__LIMITS__
"""
DEFINITIONS = [
("События формы", "Различные event_id, связанные с учитываемым лидом. Несколько событий одного лида — не новые лиды."),
("Лиды", "Различные lead_id после исключения тестовых строк и строк вне периода."),
("Квалифицированные лиды", "Лиды с qualified=true в самой строке лида; по событию формы или клику не выводится."),
("Созданные заказы", "Различные нетестовые order_id периода, связанные с учитываемым лидом. Отдельно от оплаченных."),
("Оплаченные заказы", "Заказы с успешной (succeeded) полной оплатой на всю сумму заказа; pending не выручка."),
("Впервые оплатившие компании", "Различные customer_id оплаченных заказов; в этой синтетической выборке считаются новыми, реальная «первая оплата в жизни» не выводится."),
("Затраты на привлечение", "Сумма платных и прочих затрат из channel-costs.csv; общий срез использует весь пул. Для канала без известной аллокации — неизвестно."),
("Чистая выручка", "Кассовый взгляд: успешные оплаты минус успешные возвраты той же фиксированной когорты. Не бухгалтерская выручка."),
("Чистая выручка минус затраты на привлечение", "Только эта разность, не прибыль: себестоимости, налогов и полного P&L нет."),
("Прибыль", "Неизвестна."),
("CPL / CPQL / CAC", "Затраты ÷ лиды / квалифицированные лиды / впервые оплатившие компании. Показан числитель / знаменатель; при неизвестном числителе или нулевом знаменателе — «нет данных»."),
("Доли", "Квалифицированные ÷ лиды; впервые оплатившие ÷ лиды; покрытие атрибуции — оплаченные заказы с известным источником ÷ все оплаченные заказы. Общая доля считается из общих числителя и знаменателя."),
("Атрибуция", "Первый сохранённый источник лида; пустой канал — unattributed, он не перераспределяется на другие каналы. Это не многоканальная атрибуция."),
]
def render_html(report, hashes):
e = html.escape
p = report["period"]
defs = "\n".join(f"
{e(a)}
{e(b)}
" for a, b in DEFINITIONS)
sources = "\n".join(
f"
"
for f in hashes["files"])
extra = ["Прибыль неизвестна; разница чистой выручки и затрат на привлечение — не прибыль.",
"Неизвестные значения показаны как «нет данных» или «неизвестно», не как ноль."]
limits = "\n".join(f"
{e(x)}
" for x in report["limitations"] + extra)
data = json.dumps(jsonable(report), ensure_ascii=False, separators=(",", ":"))
data = (data.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029"))
period = f"{p['start'][:10]} — {p['end_exclusive'][:10]} (конец не включается)"
out = (HTML.replace("__PERIOD__", e(period)).replace("__TZ__", e(p["timezone"]))
.replace("__METHOD__", e(report["method"]["label"]))
.replace("__DEFS__", defs).replace("__SOURCES__", sources)
.replace("__LIMITS__", limits).replace("__DATA__", data))
return out
# ---------------------------------------------------------------- main ---
def main():
try:
src = load_sources()
report = compute(src)
except CalcError as err:
sys.stderr.write("РАСЧЁТ ОСТАНОВЛЕН: данные не поддерживаются.\n")
for pr in err.problems:
sys.stderr.write(f" - {pr}\n")
return 2
# Все тексты строятся в памяти ДО любой записи; в output/ пишется только
# законченный комплект из пяти файлов (ошибка рендера ничего не оставляет).
try:
hashes = sha_info(INPUT_FILES)
texts = {
"report.json": json.dumps(jsonable(report), ensure_ascii=False, indent=2) + "\n",
"channel-report.csv": render_csv(report),
"dashboard.html": render_html(report, hashes),
"interpretation.md": render_interpretation(report),
"source-hashes.json": json.dumps(hashes, ensure_ascii=False, indent=2) + "\n",
}
except Exception as exc: # непредвиденная ошибка рендера: файлы не тронуты
sys.stderr.write(f"СБОРКА ОСТАНОВЛЕНА: {type(exc).__name__}: {exc}\n"
"Файлы в output/ не записаны.\n")
return 3
OUTPUT.mkdir(exist_ok=True)
for name, text in texts.items():
put(OUTPUT / name, text)
print("Готово: output/report.json, channel-report.csv, dashboard.html, "
"interpretation.md, source-hashes.json")
return 0
if __name__ == "__main__":
sys.exit(main())