feat(strategy-map): A3 三维切片(时间/门店/产品)——切片重算 + 显式空态 + entity 隔离
ci/woodpecker/push/main Pipeline was successful

- 新增 /api/cma/maps/{id}/dimension-options 与 /{id}/slice(只读)
- 时间:kpi_values.period 月/季/年;flow 求和、ratio 均值、balance/status 取最新
- 产品:product_sales + 逐月源一致性闸门(偏差>1% 不切片)
- 门店:库内无门店级事实 ⇒ available=false + reason,绝不回退总额
- 前端 MapDimensionFilter 挂到 MapCanvas,切换即 emit 切片,清除恢复 /review
This commit is contained in:
wecom-fullstack
2026-10-19 13:32:49 +08:00
committed by Hermes CI Fix
parent c92dbab96a
commit d85dd2a155
6 changed files with 968 additions and 1 deletions
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""A3 产品切片正例:2026-07(源一致性通过)逐产品核对。"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for line in open('/root/cma-management/backend/.env'):
line = line.strip()
if '=' in line and not line.startswith('#') and line.split('=')[0].startswith('CMA_DB'):
k, v = line.split('=', 1); os.environ.setdefault(k, v)
from app.database import get_session_local
from app.api.map_dimensions import build_slice
db = get_session_local()()
for prod in ("酣客酱酒", "禧酱", "标准版"):
r = build_slice(db, 1, 65, "2026-07", None, prod)
rev = [k for o in r["objectives"] for k in o["kpis"] if k["kpi_code"] == "F_REVENUE"][0]
print(prod, "| empty:", r["empty"], "|", r["empty_reason"], "| rev:", rev["actual_value"], "/", rev["target_value"], rev["achievement_pct"], rev["aggregation"], rev["periods_used"], "| note:", rev["reason"])
r = build_slice(db, 1, 65, "2026-Q3", None, "酣客酱酒")
rev = [k for o in r["objectives"] for k in o["kpis"] if k["kpi_code"] == "F_REVENUE"][0]
print("Q3 酣客酱酒 | empty:", r["empty"], "| rev:", rev["actual_value"], "/", rev["target_value"], rev["achievement_pct"], rev["aggregation"], rev["periods_used"], "| note:", rev["reason"])
r = build_slice(db, 1, 65, None, None, None)
print("默认(最新期):", r["slice"], [ (k["kpi_code"],k["actual_value"]) for o in r["objectives"] for k in o["kpis"] if k["available"]][:6])
print("store 空态:", build_slice(db, 1, 65, "2026-07", "一店", None)["empty_reason"])
db.close()
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""A3 自测:直接调 build_slice(不启服务),校验真实库切片与空态。"""
import os, sys, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for line in open('/root/cma-management/backend/.env'):
line = line.strip()
if '=' in line and not line.startswith('#') and line.split('=')[0].startswith(('CMA_DB', 'ERP_DB')):
k, v = line.split('=', 1); os.environ.setdefault(k, v)
from app.database import get_session_local
from app.api.map_dimensions import build_slice, _time_options, _store_options, _product_options
db = get_session_local()()
def brief(r):
print("slice:", r["slice"], "| empty:", r["empty"], "|", r["empty_reason"])
for o in r["objectives"]:
ks = [f'{k["kpi_code"]}={k["actual_value"]}/{k["target_value"]}@{k["achievement_pct"]}%({k["level"]},{k["aggregation"]}){"" if k["available"] else "[空:"+str(k["reason"])[:36]+"]"}' for k in o["kpis"]]
if ks:
print(" ", o["dimension_key"], o["name"], "|", "; ".join(ks))
print("=== 1) 2026-08 月切片 (map 65)")
brief(build_slice(db, 1, 65, "2026-08", None, None))
print("\n=== 2) 2026-Q3 季切片")
brief(build_slice(db, 1, 65, "2026-Q3", None, None))
print("\n=== 3) 2026 年切片")
brief(build_slice(db, 1, 65, "2026", None, None))
print("\n=== 4) 产品切片 酣客酱酒 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", None, "酣客酱酒"))
print("\n=== 5) 产品切片 不存在产品 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", None, "不存在的产品X"))
print("\n=== 6) 门店切片 一店 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", "一店", None))
print("\n=== 7) entity 隔离: map65 + entity2")
try:
build_slice(db, 2, 65, "2026-08", None, None)
print("FAIL 未拦截")
except Exception as e:
print("OK 拦截:", type(e).__name__, getattr(e, "status_code", ""), getattr(e, "detail", e))
print("\n=== 8) options map65")
o = build_slice(db, 1, 65, None, None, None)
print("time dims:", json.dumps(o["dims"]["time"], ensure_ascii=False)[:300])
print("store:", json.dumps(o["dims"]["store"], ensure_ascii=False)[:300])
print("product:", json.dumps(o["dims"]["product"], ensure_ascii=False)[:300])
db.close()