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.
248 lines
11 KiB
Python
248 lines
11 KiB
Python
#!/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())
|