feat(cpa): customer profitability MVP P0-a/b - revenue caliber single source + snapshot table + 9-card tri-state
Task: finance-20261003-cpa-customer-profitability-mvp-fullstack (P0-a / P0-b) - backend/app/utils/cpa_caliber.py (new): single source of truth for customer-level revenue aggregation from zxt_masterbill. Reused by alert_rules._customer_revenue_shares (dedupe: no second implementation). - backend/scripts/migrate_cpa_snapshot_20261004.py (new): idempotent DDL for customer_profitability_snapshot (UNIQUE entity_id+period+unit_id), --check / --apply / --rollback / --status / --show-create + journal. - backend/scripts/kpi_customer_profitability_deriver.py (new): monthly snapshot + kpi_values upsert (single transaction, idempotent). fail-closed: cogs_alloc / chan_sub / contrib / contrib_rate stay NULL and are never written as 0 or as an apportioned value. - backend/scripts/sql/cpa_kpi_defs_20261004.sql (new): C_CUST_REVENUE and C_CUST_COUNT active; 5 P1 codes registered inactive with fail-closed reason. - backend/app/api/customer_dashboard.py: new read-only endpoint GET /api/cma/customer-dashboard/standard-indicators returning the 9-card tri-state (value / no_data / undefined). Reads kpi_definitions via raw SQL to stay decoupled from the in-flight KPI direction DDL. - frontend/src/views/CustomerDashboard.vue: 9-card panel rendering the three states (never renders an empty value as 0). Anchor check 2026-01..09 (offline sqlite rehearsal, ALL_PASS=true): 73 customers / 4883.52 wan / top1 25.3835 / top2 23.4905 / top4 63.3249 / top10 81.7675 - all six match the task acceptance anchors. No production write and no deploy in this commit; release is gated.
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CPA 客户获利性派生器 —— 客户级收入快照 + 汇总口径 KPI 值(任务 finance-20261003-cpa-... P0-a)
|
||||
|
||||
产物
|
||||
----
|
||||
1) 明细表 `customer_profitability_snapshot`(每客户每期一行,幂等 upsert;见
|
||||
`scripts/migrate_cpa_snapshot_20261004.py`)
|
||||
2) `kpi_values` 汇总口径(**只存汇总,避免 284 客户 × 期数污染 KPI 宽表**,任务 §2.1):
|
||||
* `C_CUST_REVENUE` 期内客户收入合计(万元)
|
||||
* `C_CUST_COUNT` 期内活跃客户数(个)
|
||||
* `C_CUST_CONCENTRATION` **补值**:仅对**库内尚无该期行**的期次写入(现网定义 = 前5大客户
|
||||
收入 ÷ 期内合计 ×100;已有 2026-01~09 九期**原样保留、不覆盖**)
|
||||
|
||||
fail-closed(本器**不写**以下列/值;理由见 `app/utils/cpa_caliber.py::FAIL_CLOSED_REASONS`)
|
||||
`cogs_alloc` / `chan_sub` / `contrib` / `contrib_rate` / `C_CUST_COGS_ALLOC` /
|
||||
`C_CUST_CHAN_SUB` / `C_CUST_CONTRIB` / `C_CUST_CONTRIB_RATE` / 客户平均贡献值
|
||||
⇒ 一律不落库(禁 0、禁分摊值),只在快照行 `fail_closed_reason` 留痕。
|
||||
|
||||
口径单一事实源
|
||||
客户收入聚合 = `app/utils/cpa_caliber.py`(与 `alert_rules._customer_revenue_shares` 同一实现)。
|
||||
|
||||
用法
|
||||
----
|
||||
python3 scripts/kpi_customer_profitability_deriver.py --check # 只读预检 + 对拍锚点(默认)
|
||||
python3 scripts/kpi_customer_profitability_deriver.py --apply # 单事务幂等写
|
||||
python3 scripts/kpi_customer_profitability_deriver.py --verify # 只读写后回查
|
||||
python3 scripts/kpi_customer_profitability_deriver.py --anchor # 只跑对拍锚点断言
|
||||
|
||||
选项
|
||||
--entity N 账套(默认 1;本单主体范围 = 陕西酣客 e1)
|
||||
--db-url URL 显式连接串(离线演练 `sqlite:///<scratch>.db`)
|
||||
--since/--until 仅处理该区间(YYYY-MM)
|
||||
--batch NAME source_batch 名(默认 cpa-20261004)
|
||||
|
||||
退出码
|
||||
0 成功 | 1 fail-closed 前置不满足 / 校验失败 | 2 用法错误
|
||||
"""
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BACKEND = os.path.dirname(HERE)
|
||||
if BACKEND not in sys.path:
|
||||
sys.path.insert(0, BACKEND)
|
||||
|
||||
DEFAULT_BATCH = "cpa-20261004"
|
||||
SNAPSHOT_TABLE = "customer_profitability_snapshot"
|
||||
FAIL_CLOSED_NOTE = (
|
||||
"cogs_alloc/chan_sub/contrib/contrib_rate = P1 fail-closed(未裁定口径/未打标),禁静默 0 或分摊值"
|
||||
)
|
||||
|
||||
ANCHOR_2026_01_09 = {"units": 73, "total_wan": 4883.52, "top1": 25.3835, "top2": 23.4905,
|
||||
"top4_cum": 63.3249, "top10_cum": 81.7675}
|
||||
ANCHOR_TOL = 0.1
|
||||
|
||||
|
||||
def _engine(args):
|
||||
from sqlalchemy import create_engine
|
||||
if args.db_url:
|
||||
url = args.db_url
|
||||
else:
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(BACKEND, ".env"), override=False)
|
||||
except Exception:
|
||||
pass
|
||||
user = os.environ.get("CMA_DB_USER", "cma_api")
|
||||
pwd = os.environ.get("CMA_DB_PASS", "")
|
||||
host = os.environ.get("CMA_DB_HOST", "127.0.0.1")
|
||||
port = os.environ.get("CMA_DB_PORT", "3306")
|
||||
name = os.environ.get("CMA_DB_NAME", "cma")
|
||||
url = f"mysql+pymysql://{user}:{quote_plus(pwd)}@{host}:{port}/{name}?charset=utf8mb4"
|
||||
kw = {"connect_args": {"check_same_thread": False}} if url.startswith("sqlite") else {}
|
||||
return create_engine(url, **kw), url
|
||||
|
||||
|
||||
def _dialect(url):
|
||||
return "sqlite" if url.startswith("sqlite") else "mysql"
|
||||
|
||||
|
||||
def _table_exists(conn, dialect):
|
||||
from sqlalchemy import text
|
||||
if dialect == "sqlite":
|
||||
return bool(conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name=:t"),
|
||||
{"t": SNAPSHOT_TABLE}).fetchall())
|
||||
return bool(conn.execute(text("SELECT TABLE_NAME FROM information_schema.TABLES "
|
||||
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:t"),
|
||||
{"t": SNAPSHOT_TABLE}).fetchall())
|
||||
|
||||
|
||||
def _grants_ok(conn, dialect, table):
|
||||
"""写前置:应用账号须对 kpi_values 与目标表有 DML。"""
|
||||
from sqlalchemy import text
|
||||
if dialect == "sqlite":
|
||||
return True, "sqlite(离线演练,无授权面)"
|
||||
rows = conn.execute(text("SHOW GRANTS FOR CURRENT_USER()")).fetchall()
|
||||
joined = "\n".join(r[0] for r in rows)
|
||||
has_kv = ("kpi_values" in joined) or ("ON `cma`.*" in joined) or ("ON *.*" in joined)
|
||||
has_new = (f"`{table}`" in joined) or ("ON `cma`.*" in joined) or ("ON *.*" in joined)
|
||||
return (has_kv and has_new), joined
|
||||
|
||||
|
||||
def _load_caliber():
|
||||
from app.utils import cpa_caliber
|
||||
return cpa_caliber
|
||||
|
||||
|
||||
def compute(engine, dialect, entity_id):
|
||||
"""只读计算:{period: {unit_id: amount}} + 汇总。"""
|
||||
cal = _load_caliber()
|
||||
monthly = cal.fetch_monthly_revenue(engine, entity_id)
|
||||
summary = {}
|
||||
for p, pairs in monthly.items():
|
||||
m = cal.monthly_metrics(pairs)
|
||||
m["_total_yuan"] = sum(a for _, a in pairs)
|
||||
signature = _rank_signature(pairs) if len(pairs) > 10 else None
|
||||
m["_signature"] = signature
|
||||
summary[p] = m
|
||||
return monthly, summary
|
||||
|
||||
|
||||
def _rank_signature(pairs):
|
||||
"""前10名 (unit_id, 分) 指纹(防静默漂移)。"""
|
||||
return [(u, round(a, 2)) for u, a in pairs[:10]]
|
||||
|
||||
|
||||
def anchor_check(summary):
|
||||
"""2026-01~09 对拍锚点(与任务验收标准逐项比)。"""
|
||||
pairs = []
|
||||
for p in sorted(summary):
|
||||
if "2026-01" <= p <= "2026-09":
|
||||
pairs.append(p)
|
||||
# 需要按窗口重算 topN(不能逐月相加),故单独取窗口
|
||||
return pairs
|
||||
|
||||
|
||||
def run(engine, dialect, entity_id, batch, apply=False, verify=False, since=None, until=None):
|
||||
cal = _load_caliber()
|
||||
from sqlalchemy import text
|
||||
result = {"entity_id": entity_id, "batch": batch, "apply": apply, "verify": verify}
|
||||
|
||||
with engine.connect() as conn:
|
||||
exists = _table_exists(conn, dialect)
|
||||
if apply and not exists:
|
||||
raise SystemExit("FAIL-CLOSED: 目标表不存在,请先跑 migrate_cpa_snapshot_20261004.py --apply")
|
||||
rows_before = None
|
||||
if exists:
|
||||
rows_before = conn.execute(text(f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e"),
|
||||
{"e": entity_id}).scalar()
|
||||
kpi_ids = {}
|
||||
for code in ("C_CUST_REVENUE", "C_CUST_COUNT", "C_CUST_CONCENTRATION"):
|
||||
r = conn.execute(text("SELECT id FROM kpi_definitions WHERE entity_id=:e AND kpi_code=:c"),
|
||||
{"e": entity_id, "c": code}).fetchone()
|
||||
kpi_ids[code] = r[0] if r else None
|
||||
existing_conc_periods = set()
|
||||
if kpi_ids["C_CUST_CONCENTRATION"]:
|
||||
for (p,) in conn.execute(text("SELECT period FROM kpi_values WHERE entity_id=:e AND kpi_id=:k"),
|
||||
{"e": entity_id, "k": kpi_ids["C_CUST_CONCENTRATION"]}).fetchall():
|
||||
existing_conc_periods.add(p)
|
||||
|
||||
monthly = cal.fetch_monthly_revenue(engine, entity_id)
|
||||
periods = sorted(p for p in monthly if (not since or p >= since) and (not until or p <= until))
|
||||
result["periods"] = len(periods)
|
||||
result["period_range"] = [periods[0], periods[-1]] if periods else None
|
||||
result["kpi_ids"] = kpi_ids
|
||||
result["existing_concentration_periods"] = sorted(existing_conc_periods)
|
||||
|
||||
if verify:
|
||||
return _verify(engine, dialect, entity_id, monthly, periods, kpi_ids, existing_conc_periods, now_ts=None)
|
||||
|
||||
snap_rows = []
|
||||
kpi_rows = []
|
||||
for p in periods:
|
||||
pairs = monthly[p]
|
||||
total_yuan = sum(a for _, a in pairs)
|
||||
for idx, (uid, amount) in enumerate(pairs, start=1):
|
||||
snap_rows.append({
|
||||
"entity_id": entity_id, "period": p, "unit_id": uid,
|
||||
"revenue": cal.to_wan(amount), "rank_no": idx,
|
||||
"calib_status": "revenue_only", "fail_closed_reason": FAIL_CLOSED_NOTE,
|
||||
})
|
||||
kpi_rows.append(("C_CUST_REVENUE", p, cal.to_wan(total_yuan)))
|
||||
kpi_rows.append(("C_CUST_COUNT", p, float(cal.active_customer_count(pairs))))
|
||||
if p not in existing_conc_periods:
|
||||
kpi_rows.append(("C_CUST_CONCENTRATION", p, cal.concentration_pct(pairs, 5)))
|
||||
result["snapshot_rows"] = len(snap_rows)
|
||||
result["kpi_rows"] = len(kpi_rows)
|
||||
result["kpi_rows_skipped_no_definition"] = [c for c, i in kpi_ids.items() if i is None]
|
||||
|
||||
if not apply:
|
||||
result["preview"] = {
|
||||
"first_periods": {p: {k: v for k, v in cal.monthly_metrics(monthly[p]).items()} for p in periods[:2]},
|
||||
"last_periods": {p: {k: v for k, v in cal.monthly_metrics(monthly[p]).items()} for p in periods[-2:]},
|
||||
}
|
||||
return result
|
||||
|
||||
# ---- 写路径(单事务) ----
|
||||
with engine.connect() as conn:
|
||||
ok, grants = _grants_ok(conn, dialect, SNAPSHOT_TABLE)
|
||||
result["grants_ok"] = ok
|
||||
if not ok:
|
||||
raise SystemExit("FAIL-CLOSED: 应用账号缺 DML 授权(kpi_values / 目标表)。原始 SHOW GRANTS:\n" + grants)
|
||||
missing = [c for c, i in kpi_ids.items() if i is None]
|
||||
if missing:
|
||||
raise SystemExit("FAIL-CLOSED: KPI 定义缺失 %s —— 请先执行 scripts/sql/cpa_kpi_defs_20261004.sql" % missing)
|
||||
|
||||
now_ts = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
inserted_snap = updated_snap = 0
|
||||
kpi_inserted = kpi_updated = 0
|
||||
with engine.begin() as conn:
|
||||
for r in snap_rows:
|
||||
row = conn.execute(text(
|
||||
f"SELECT id FROM {SNAPSHOT_TABLE} WHERE entity_id=:entity_id AND period=:period AND unit_id=:unit_id"),
|
||||
r).fetchone()
|
||||
if row:
|
||||
conn.execute(text(
|
||||
f"UPDATE {SNAPSHOT_TABLE} SET revenue=:revenue, rank_no=:rank_no, "
|
||||
"calib_status=:calib_status, fail_closed_reason=:fail_closed_reason, updated_at=:ts "
|
||||
"WHERE id=:id"), {**r, "ts": now_ts, "id": row[0]})
|
||||
updated_snap += 1
|
||||
else:
|
||||
conn.execute(text(
|
||||
f"INSERT INTO {SNAPSHOT_TABLE} (entity_id, period, unit_id, revenue, rank_no, calib_status, "
|
||||
"fail_closed_reason, created_at, updated_at) VALUES (:entity_id, :period, :unit_id, :revenue, "
|
||||
":rank_no, :calib_status, :fail_closed_reason, :ts, :ts)"), {**r, "ts": now_ts})
|
||||
inserted_snap += 1
|
||||
for code, period, value in kpi_rows:
|
||||
kid = kpi_ids[code]
|
||||
if value is None:
|
||||
continue # fail-closed:不给值(不写 NULL 新行,避免污染宽表)
|
||||
row = conn.execute(text(
|
||||
"SELECT id, actual_value, data_status FROM kpi_values "
|
||||
"WHERE entity_id=:e AND kpi_id=:k AND period=:p ORDER BY id DESC LIMIT 1"),
|
||||
{"e": entity_id, "k": kid, "p": period}).fetchone()
|
||||
remark = {"C_CUST_REVENUE": "口径=Σzxt_masterbill.sum_money(期内、unit_id>0、含税净额>0 取舍)",
|
||||
"C_CUST_COUNT": "口径=期内 zxt_masterbill 活跃往来单位数(unit_id 去重)",
|
||||
"C_CUST_CONCENTRATION": "口径=前5大客户收入/期内客户收入合计×100%(补值期次;"
|
||||
"2026-01~09 现网值原样保留)"}[code]
|
||||
if row:
|
||||
# 幂等:值不变则零写入;不降级 data_status
|
||||
if abs(float(row[1] or 0) - float(value)) < 1e-9 and row[2]:
|
||||
continue
|
||||
conn.execute(text("UPDATE kpi_values SET actual_value=:v, source_type='derived', source_batch=:b, "
|
||||
"remark=:r WHERE id=:id"),
|
||||
{"v": value, "b": batch, "r": remark, "id": row[0]})
|
||||
kpi_updated += 1
|
||||
else:
|
||||
conn.execute(text(
|
||||
"INSERT INTO kpi_values (entity_id, kpi_id, period, actual_value, source_type, source_batch, "
|
||||
"data_status, calculated_at, remark) VALUES (:e, :k, :p, :v, 'derived', :b, 'pending', :ts, :r)"),
|
||||
{"e": entity_id, "k": kid, "p": period, "v": value, "b": batch, "ts": now_ts, "r": remark})
|
||||
kpi_inserted += 1
|
||||
result.update(snapshot_inserted=inserted_snap, snapshot_updated=updated_snap,
|
||||
kpi_inserted=kpi_inserted, kpi_updated=kpi_updated, ts=now_ts)
|
||||
return result
|
||||
|
||||
|
||||
def _verify(engine, dialect, entity_id, monthly, periods, kpi_ids, existing_conc_periods, now_ts=None):
|
||||
"""只读写后回查:快照行数/值锚点 + kpi_values 三族。"""
|
||||
from sqlalchemy import text
|
||||
cal = _load_caliber()
|
||||
out = {"verify": True, "entity_id": entity_id}
|
||||
with engine.connect() as conn:
|
||||
out["snapshot_rows"] = conn.execute(text(
|
||||
f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e"), {"e": entity_id}).scalar()
|
||||
out["snapshot_periods"] = conn.execute(text(
|
||||
f"SELECT COUNT(DISTINCT period) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e"), {"e": entity_id}).scalar()
|
||||
# 值锚点:2026-01~09 快照合计 == 4,883.52 万
|
||||
r = conn.execute(text(
|
||||
f"SELECT ROUND(SUM(revenue),2), COUNT(DISTINCT unit_id) FROM {SNAPSHOT_TABLE} "
|
||||
"WHERE entity_id=:e AND period>='2026-01' AND period<='2026-09'"), {"e": entity_id}).fetchone()
|
||||
out["anchor_2026_01_09_snapshot"] = {"total_wan": float(r[0] or 0), "units": int(r[1] or 0)}
|
||||
out["fail_closed_columns_all_null"] = conn.execute(text(
|
||||
f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e AND "
|
||||
"(cogs_alloc IS NOT NULL OR chan_sub IS NOT NULL OR contrib IS NOT NULL OR contrib_rate IS NOT NULL)"),
|
||||
{"e": entity_id}).scalar()
|
||||
# kpi_values 回查
|
||||
kv = {}
|
||||
for code in ("C_CUST_REVENUE", "C_CUST_COUNT", "C_CUST_CONCENTRATION"):
|
||||
kid = kpi_ids.get(code)
|
||||
if not kid:
|
||||
kv[code] = None
|
||||
continue
|
||||
rows = conn.execute(text("SELECT period, actual_value, data_status FROM kpi_values "
|
||||
"WHERE entity_id=:e AND kpi_id=:k ORDER BY period"),
|
||||
{"e": entity_id, "k": kid}).fetchall()
|
||||
kv[code] = {"n": len(rows), "sample": [[r[0], float(r[1]) if r[1] is not None else None] for r in rows[:3]],
|
||||
"last": [[r[0], float(r[1]) if r[1] is not None else None] for r in rows[-2:]]}
|
||||
out["kpi_values"] = kv
|
||||
# 期次覆盖:快照期次 == 有数据期次
|
||||
out["periods_with_data"] = len(periods)
|
||||
out["concentration_untouched_note"] = (
|
||||
"现网 2026-01~09 九期在写前已存在 ⇒ 本器不覆盖(见 existing_concentration_periods)")
|
||||
out["existing_concentration_periods"] = sorted(existing_conc_periods)
|
||||
return out
|
||||
|
||||
|
||||
def anchor_assert(engine, entity_id):
|
||||
"""2026-01~09 窗口对拍锚点(任务验收标准逐项)。"""
|
||||
from sqlalchemy import text
|
||||
cal = _load_caliber()
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(
|
||||
"SELECT unit_id, SUM(sum_money) AS amount FROM zxt_masterbill WHERE entity_id=:e "
|
||||
"AND unit_id IS NOT NULL AND unit_id>0 AND (sum_money + sum_tax_money) > 0 "
|
||||
"AND bill_date>='2026-01-01' AND bill_date<='2026-09-30' GROUP BY unit_id ORDER BY amount DESC"),
|
||||
{"e": entity_id}).fetchall()
|
||||
pairs = [(int(r[0]), float(r[1])) for r in rows]
|
||||
total = sum(a for _, a in pairs)
|
||||
cum = lambda n: round(sum(a for _, a in pairs[:n]) / total * 100, 4) if total else None
|
||||
got = {"units": len(pairs), "total_wan": round(total / 10000, 2),
|
||||
"top1": round(pairs[0][1] / total * 100, 4) if pairs else None,
|
||||
"top2": round(pairs[1][1] / total * 100, 4) if len(pairs) > 1 else None,
|
||||
"top4_cum": cum(4), "top10_cum": cum(10)}
|
||||
checks = {}
|
||||
for k, exp in ANCHOR_2026_01_09.items():
|
||||
v = got.get(k)
|
||||
checks[k] = {"expected": exp, "got": v, "pass": (v is not None and abs(float(v) - exp) <= ANCHOR_TOL)}
|
||||
return {"anchor": got, "checks": checks, "ALL_PASS": all(c["pass"] for c in checks.values())}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
g.add_argument("--verify", action="store_true")
|
||||
g.add_argument("--anchor", action="store_true")
|
||||
ap.add_argument("--entity", type=int, default=1)
|
||||
ap.add_argument("--db-url")
|
||||
ap.add_argument("--batch", default=DEFAULT_BATCH)
|
||||
ap.add_argument("--since")
|
||||
ap.add_argument("--until")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
engine, url = _engine(args)
|
||||
dialect = _dialect(url)
|
||||
|
||||
if args.anchor:
|
||||
res = anchor_assert(engine, args.entity)
|
||||
print(json.dumps(res, ensure_ascii=False, indent=2))
|
||||
return 0 if res["ALL_PASS"] else 1
|
||||
|
||||
res = run(engine, dialect, args.entity, args.batch, apply=args.apply,
|
||||
verify=args.verify, since=args.since, until=args.until)
|
||||
print(json.dumps(res, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CPA 客户获利性明细快照表 —— 独立特权 DDL 步(幂等 / 可回滚 / 带 journal)
|
||||
|
||||
任务:finance-20261003-cpa-customer-profitability-mvp-fullstack(P0-a)
|
||||
|
||||
为什么是独立脚本而不是 ORM 模型
|
||||
------------------------------
|
||||
运行库账号 `cma_api` 只有 SELECT + 表级 DML 白名单(**无 DDL**),建表须走特权通道
|
||||
(本机 unix socket + root,或专用迁移账号)。同时 `app/models/__init__.py` 此刻有**他单在途
|
||||
改动**(CMA-KPI-20261003 KPI 字典分层治理),本单不碰该共享文件 ⇒ 新表以独立迁移脚本落地,
|
||||
运行时以 raw SQL 访问(与既有 `zxt_masterbill` 同一形态)。
|
||||
|
||||
用法
|
||||
----
|
||||
python3 scripts/migrate_cpa_snapshot_20261004.py --check # 只读预检(默认)
|
||||
python3 scripts/migrate_cpa_snapshot_20261004.py --apply # 幂等建表 + 写 journal
|
||||
python3 scripts/migrate_cpa_snapshot_20261004.py --status # 历史 journal
|
||||
python3 scripts/migrate_cpa_snapshot_20261004.py --rollback latest
|
||||
python3 scripts/migrate_cpa_snapshot_20261004.py --show-create
|
||||
|
||||
选项
|
||||
----
|
||||
--db-url URL 显式连接串(离线演练用 `sqlite:///<scratch>.db`)
|
||||
--db-name NAME 目标库名(默认 env CMA_MIGRATE_DB_NAME → CMA_DB_NAME → cma)
|
||||
--journal-dir 默认 env CPA_MIGRATE_JOURNAL_DIR 或 backend/scripts/backups/db-migrate-journal/
|
||||
--force 回滚时允许删除**有数据**的表(默认拒绝,exit 3)
|
||||
--json 机器可读输出
|
||||
|
||||
连接凭据(口令只从环境读,禁止硬编码/回显)
|
||||
1) CMA_MIGRATE_DB_USER + CMA_MIGRATE_DB_PASS(专用迁移账号,TCP)
|
||||
2) 未设 → 本机 unix socket + root(socket 认证)
|
||||
⚠️ 应用运行账号(backend/.env 的 CMA_DB_USER = cma_api)不得被本脚本使用:无 DDL 权限。
|
||||
|
||||
幂等契约
|
||||
首跑 --apply:created=1;二跑 --apply:created=0 且零副作用(不 ALTER / 不 DROP / 不动数据行)。
|
||||
|
||||
退出码
|
||||
0 成功 | 1 失败(fail-closed)| 2 用法错误 | 3 回滚被拒(目标表有数据且未 --force)
|
||||
"""
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
TABLE = "customer_profitability_snapshot"
|
||||
|
||||
MYSQL_DDL = f"""
|
||||
CREATE TABLE IF NOT EXISTS `{TABLE}` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`entity_id` INT NOT NULL COMMENT '企业ID(账套,多租户隔离)',
|
||||
`period` VARCHAR(7) NOT NULL COMMENT '期间 YYYY-MM(自然月,来源 zxt_masterbill.bill_date)',
|
||||
`unit_id` INT NOT NULL COMMENT '客户/往来单位ID(zxt_masterbill.unit_id;库内无名称源)',
|
||||
`revenue` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '客户收入(万元;Σsum_money,含税原值口径)',
|
||||
`cogs_alloc` DECIMAL(18,4) NULL COMMENT '客户销货成本(分摊)(万元)——P1 fail-closed,未裁定口径前恒 NULL',
|
||||
`chan_sub` DECIMAL(18,4) NULL COMMENT '客户渠道补贴(万元)——P1 待打标台账,恒 NULL',
|
||||
`contrib` DECIMAL(18,4) NULL COMMENT '客户贡献毛利(万元)——P1,恒 NULL',
|
||||
`contrib_rate` DECIMAL(10,4) NULL COMMENT '客户贡献毛利率(%)——P1,恒 NULL',
|
||||
`rank_no` INT NULL COMMENT '期内收入排名(1=最高)',
|
||||
`calib_status` VARCHAR(30) NOT NULL DEFAULT 'revenue_only' COMMENT '口径状态: revenue_only / full(P1)',
|
||||
`fail_closed_reason` VARCHAR(500) NULL COMMENT '未落值列的原因(fail-closed 留痕,禁静默 0)',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_cpa_snap_entity_period_unit` (`entity_id`, `period`, `unit_id`),
|
||||
KEY `ix_cpa_snap_entity_period_rank` (`entity_id`, `period`, `rank_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户获利性明细快照(CPA P0-a;每客户每期一行,幂等 upsert)'
|
||||
"""
|
||||
|
||||
SQLITE_DDL = f"""
|
||||
CREATE TABLE IF NOT EXISTS {TABLE} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
unit_id INTEGER NOT NULL,
|
||||
revenue NUMERIC NOT NULL DEFAULT 0,
|
||||
cogs_alloc NUMERIC,
|
||||
chan_sub NUMERIC,
|
||||
contrib NUMERIC,
|
||||
contrib_rate NUMERIC,
|
||||
rank_no INTEGER,
|
||||
calib_status TEXT NOT NULL DEFAULT 'revenue_only',
|
||||
fail_closed_reason TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_cpa_snap_entity_period_unit UNIQUE (entity_id, period, unit_id)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _now():
|
||||
return _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def _journal_dir(arg):
|
||||
if arg:
|
||||
return arg
|
||||
env = os.environ.get("CPA_MIGRATE_JOURNAL_DIR")
|
||||
if env:
|
||||
return env
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
d = os.path.join(here, "backups", "db-migrate-journal")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _resolve_url(args):
|
||||
if args.db_url:
|
||||
return args.db_url
|
||||
if os.environ.get("CMA_MIGRATE_DB_URL"):
|
||||
return os.environ["CMA_MIGRATE_DB_URL"]
|
||||
user = os.environ.get("CMA_MIGRATE_DB_USER")
|
||||
pwd = os.environ.get("CMA_MIGRATE_DB_PASS", "")
|
||||
if user:
|
||||
host = os.environ.get("CMA_MIGRATE_DB_HOST", "127.0.0.1")
|
||||
port = os.environ.get("CMA_MIGRATE_DB_PORT", "3306")
|
||||
name = args.db_name or os.environ.get("CMA_MIGRATE_DB_NAME") or os.environ.get("CMA_DB_NAME") or "cma"
|
||||
from urllib.parse import quote_plus
|
||||
return f"mysql+pymysql://{user}:{quote_plus(pwd)}@{host}:{port}/{name}?charset=utf8mb4"
|
||||
name = args.db_name or os.environ.get("CMA_DB_NAME") or "cma"
|
||||
return f"mysql+pymysql://root@localhost/{name}?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4"
|
||||
|
||||
|
||||
def _engine(args):
|
||||
from sqlalchemy import create_engine
|
||||
url = _resolve_url(args)
|
||||
kw = {}
|
||||
if url.startswith("sqlite"):
|
||||
kw["connect_args"] = {"check_same_thread": False}
|
||||
return create_engine(url, **kw), url
|
||||
|
||||
|
||||
def _table_exists(conn, url):
|
||||
from sqlalchemy import text
|
||||
if url.startswith("sqlite"):
|
||||
r = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name=:t"), {"t": TABLE}).fetchall()
|
||||
else:
|
||||
r = conn.execute(text("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:t"),
|
||||
{"t": TABLE}).fetchall()
|
||||
return bool(r)
|
||||
|
||||
|
||||
def _rowcount(conn):
|
||||
from sqlalchemy import text
|
||||
try:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM {TABLE}")).scalar() or 0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", action="store_true")
|
||||
g.add_argument("--apply", action="store_true")
|
||||
g.add_argument("--status", action="store_true")
|
||||
g.add_argument("--rollback", nargs="?", const="latest")
|
||||
g.add_argument("--show-create", action="store_true")
|
||||
ap.add_argument("--db-url")
|
||||
ap.add_argument("--db-name")
|
||||
ap.add_argument("--journal-dir")
|
||||
ap.add_argument("--force", action="store_true")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
engine, url = _engine(args)
|
||||
redacted = url.split("//")[0] + "//***"
|
||||
dialect = "sqlite" if url.startswith("sqlite") else "mysql"
|
||||
out = {"dialect": dialect, "table": TABLE, "url": redacted}
|
||||
|
||||
if args.status:
|
||||
d = _journal_dir(args.journal_dir)
|
||||
files = sorted(f for f in os.listdir(d) if f.startswith("cpa-snapshot-"))
|
||||
out["journals"] = files
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else "\n".join(files) or "(no journal)")
|
||||
return 0
|
||||
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
exists = _table_exists(conn, url)
|
||||
rows = _rowcount(conn) if exists else None
|
||||
out.update(existing=exists, rowcount=rows)
|
||||
|
||||
if args.show_create:
|
||||
if not exists:
|
||||
out["show_create"] = None
|
||||
else:
|
||||
if dialect == "sqlite":
|
||||
out["show_create"] = conn.execute(text(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name=:t"), {"t": TABLE}).scalar()
|
||||
else:
|
||||
out["show_create"] = conn.execute(text(f"SHOW CREATE TABLE `{TABLE}`")).fetchone()[1]
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
if args.rollback is not None:
|
||||
if not exists:
|
||||
out["action"] = "rollback-noop"; print(json.dumps(out, ensure_ascii=False)); return 0
|
||||
if rows and not args.force:
|
||||
out["action"] = "rollback-refused"; out["why"] = f"表内有 {rows} 行,未加 --force"
|
||||
print(json.dumps(out, ensure_ascii=False)); return 3
|
||||
jdir = _journal_dir(args.journal_dir)
|
||||
jf = os.path.join(jdir, f"cpa-snapshot-rollback-{_now()}.json")
|
||||
with open(jf, "w") as fh:
|
||||
json.dump({"table": TABLE, "rows_before": rows, "ts": _now()}, fh, ensure_ascii=False)
|
||||
with engine.begin() as c2:
|
||||
c2.execute(text(f"DROP TABLE {TABLE}" if dialect == "sqlite" else f"DROP TABLE `{TABLE}`"))
|
||||
out["action"] = "rolled-back"; out["journal"] = jf
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
if not args.apply:
|
||||
# --check:只读预检
|
||||
out["action"] = "check"
|
||||
out["would_create"] = not exists
|
||||
if exists:
|
||||
cols = conn.execute(text(
|
||||
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:t ORDER BY ORDINAL_POSITION"
|
||||
), {"t": TABLE}).fetchall() if dialect == "mysql" else conn.execute(
|
||||
text(f"PRAGMA table_info({TABLE})")).fetchall()
|
||||
out["columns"] = [c[0] for c in cols] if dialect == "mysql" else [c[1] for c in cols]
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
|
||||
f"[check] table={TABLE} existing={exists} rowcount={rows} would_create={not exists}")
|
||||
return 0
|
||||
|
||||
# --apply:幂等建表
|
||||
ddl = SQLITE_DDL if dialect == "sqlite" else MYSQL_DDL
|
||||
jdir = _journal_dir(args.journal_dir)
|
||||
created_new = 0
|
||||
if not exists:
|
||||
with engine.begin() as c2:
|
||||
c2.execute(text(ddl))
|
||||
created_new = 1
|
||||
with engine.connect() as conn3:
|
||||
assert _table_exists(conn3, url), "建表后自检失败"
|
||||
jf = os.path.join(jdir, f"cpa-snapshot-{_now()}{'' if created_new else '-noop'}.json")
|
||||
with open(jf, "w") as fh:
|
||||
json.dump({"table": TABLE, "created": created_new, "ddl": ddl.strip(), "ts": _now()}, fh, ensure_ascii=False, indent=2)
|
||||
out.update(action="applied", created_new=created_new, journal=jf,
|
||||
rowcount_after=_rowcount(conn3))
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,123 @@
|
||||
-- CMA 客户获利能力(CPA / Customer Profitability)KPI 定义 — 2026-10-04
|
||||
-- 任务: finance-20261003-cpa-customer-profitability-mvp-fullstack(P0-a;派单方 财务Bot,放行 任总 2026-10-03 18:17 CST DM「立」)
|
||||
-- 主体范围: 陕西酣客 entity_id=1(zxt_masterbill 唯一有数主体)
|
||||
-- 口径裁定与失败原因: 见 backend/app/utils/cpa_caliber.py 与
|
||||
-- backend/scripts/kpi_customer_profitability_deriver.py 头部文档
|
||||
--
|
||||
-- 动作:
|
||||
-- ① 新增 C_CUST_REVENUE 客户收入(万元) status=active ← 派生器当即可供值
|
||||
-- ② 新增 C_CUST_COUNT 客户数(个) status=active ← 派生器当即可供值
|
||||
-- ③ 新增 5 条 P1 定义(fail-closed,**不供值**)status=inactive ← 口径已登记,待数据/裁定
|
||||
-- C_CUST_COGS_ALLOC / C_CUST_CHAN_SUB / C_CUST_CONTRIB / C_CUST_CONTRIB_RATE / C_CUST_AVG_CONTRIB
|
||||
-- (inactive 而非 active:避免「有定义无值」在看板上伪装成「无数据」,P0-b 三态按「未定义·待建」显示)
|
||||
-- * C_CUST_CONCENTRATION 复用既有 kpi_id=23(formula 前5大客户收入/总收入×100%),本 SQL 不新增/不改名;
|
||||
-- 其 2026-01~09 九期现网值(source_batch finance-20261002-kpi6, verified)**原样保留**,派生器只补缺口期次。
|
||||
--
|
||||
-- 幂等: NOT EXISTS 守卫,可安全重跑(重跑=零副作用)
|
||||
-- 注意: (entity_id, kpi_code) 唯一键 uk_entity_kpi
|
||||
|
||||
-- ① 客户收入(万元)
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||
kpi_level, status, epic, bot_source, data_source_config)
|
||||
SELECT 1, 'C_CUST_REVENUE', '客户收入', 'customer', 'customer_profitability',
|
||||
'Σ 客户销售额(万元)',
|
||||
'期内客户收入合计 = Σ zxt_masterbill.sum_money(entity_id=1, unit_id>0, sum_money>0, bill_date 期内)÷10000;'
|
||||
'口径单一事实源 app/utils/cpa_caliber.py(与客户集中度预警同源)',
|
||||
'erp', '智享通销售单据 zxt_masterbill(unit_id=往来单位/客户)', '财务部', 'monthly', '万元', 'amount',
|
||||
'strategic', 'active', 'Epic2', 'fullstack-bot',
|
||||
'{"source_ids": [], "deriver": "scripts/kpi_customer_profitability_deriver.py", "basis": "zxt_masterbill.sum_money(含税原值)"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'C_CUST_REVENUE') AS t);
|
||||
|
||||
-- ② 客户数(个)
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||
kpi_level, status, epic, bot_source, data_source_config)
|
||||
SELECT 1, 'C_CUST_COUNT', '客户数', 'customer', 'customer_profitability',
|
||||
'期内活跃客户数(个)',
|
||||
'期内 zxt_masterbill 活跃往来单位数(unit_id 去重;含极小金额客户,与客户收入聚合同过滤)',
|
||||
'erp', '智享通销售单据 zxt_masterbill', '财务部', 'monthly', '个', 'number',
|
||||
'strategic', 'active', 'Epic2', 'fullstack-bot',
|
||||
'{"source_ids": [], "deriver": "scripts/kpi_customer_profitability_deriver.py"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'C_CUST_COUNT') AS t);
|
||||
|
||||
-- ③ P1 fail-closed 定义(口径已登记,供值待裁定/待打标;status=inactive)
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||
kpi_level, status, epic, bot_source, data_source_config)
|
||||
SELECT 1, 'C_CUST_COGS_ALLOC', '客户销货成本(分摊)', 'customer', 'customer_profitability',
|
||||
'客户收入 × 公司整体销货成本率(5401÷5001)',
|
||||
'【P1 fail-closed 未供值】基数口径未决:客户收入基座 zxt_masterbill 含税单据流(2026-01~09=4,883.52万)'
|
||||
'与凭证账 5401/5001(收入量级~900万)相差约5.4×,直接相乘无经济意义;且须与「统一后的 CVP 变动成本口径」'
|
||||
'(finance-20261003-cvp-fix-and-bohai-coverage 第⑤项)一致 ⇒ 待财务裁定基数后落值。禁写 0/分摊值。',
|
||||
'derived', '待建(scripts/kpi_customer_profitability_deriver.py 已留位)', '财务部', 'monthly', '万元', 'amount',
|
||||
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||
'{"fail_closed": true, "reason_code": "cogs_base_undecided", "pending_on": "finance-20261003-cvp-fix-and-bohai-coverage#5"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'C_CUST_COGS_ALLOC') AS t);
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||
kpi_level, status, epic, bot_source, data_source_config)
|
||||
SELECT 1, 'C_CUST_CHAN_SUB', '客户渠道补贴', 'customer', 'customer_profitability',
|
||||
'Σ 客户专属渠道补贴',
|
||||
'【P1 fail-closed 未供值】571103 渠道补贴目前仅有总额、不可按客户/渠道拆分;须先落「渠道补贴台账」'
|
||||
'(人员/渠道/客户 × 期间 × 金额)再取值。禁按收入占比硬摊。',
|
||||
'derived', '待建(渠道补贴打标台账)', '财务部', 'monthly', '万元', 'amount',
|
||||
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||
'{"fail_closed": true, "reason_code": "channel_subsidy_untagged"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'C_CUST_CHAN_SUB') AS t);
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||
kpi_level, status, epic, bot_source, data_source_config)
|
||||
SELECT 1, 'C_CUST_CONTRIB', '客户贡献毛利', 'customer', 'customer_profitability',
|
||||
'客户收入 − 客户销货成本(分摊) − 客户专属渠道补贴',
|
||||
'【P1 fail-closed 未供值】依赖 C_CUST_COGS_ALLOC 与 C_CUST_CHAN_SUB,二者皆未落值 ⇒ 不计算、不写 0。',
|
||||
'derived', '待建', '财务部', 'monthly', '万元', 'amount',
|
||||
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||
'{"fail_closed": true, "reason_code": "depends_on_cogs_alloc_and_chan_sub"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'C_CUST_CONTRIB') AS t);
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||
kpi_level, status, epic, bot_source, data_source_config)
|
||||
SELECT 1, 'C_CUST_CONTRIB_RATE', '客户贡献毛利率', 'customer', 'customer_profitability',
|
||||
'客户贡献毛利 ÷ 客户收入 × 100%',
|
||||
'【P1 fail-closed 未供值】依赖 C_CUST_CONTRIB ⇒ 不计算。',
|
||||
'derived', '待建', '财务部', 'monthly', '%', 'ratio',
|
||||
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||
'{"fail_closed": true, "reason_code": "depends_on_contrib"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'C_CUST_CONTRIB_RATE') AS t);
|
||||
|
||||
INSERT INTO kpi_definitions
|
||||
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||
kpi_level, status, epic, bot_source, data_source_config)
|
||||
SELECT 1, 'C_CUST_AVG_CONTRIB', '客户平均贡献值', 'customer', 'customer_profitability',
|
||||
'期内客户贡献毛利合计 ÷ 期内活跃客户数',
|
||||
'【P1 fail-closed 未供值】依赖 C_CUST_CONTRIB ⇒ 不计算。页面「客户平均贡献值」卡即此码;'
|
||||
'P0 阶段按「未定义·待建」显示(定义已登记但 inactive)。',
|
||||
'derived', '待建', '财务部', 'monthly', '万元', 'amount',
|
||||
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||
'{"fail_closed": true, "reason_code": "depends_on_contrib"}'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||
WHERE entity_id = 1 AND kpi_code = 'C_CUST_AVG_CONTRIB') AS t);
|
||||
Reference in New Issue
Block a user