init: 项目初始提交 - NeoZQYY Monorepo 完整代码

This commit is contained in:
Neo
2026-02-15 14:58:14 +08:00
commit ded6dfb9d8
769 changed files with 182616 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""Export cfg_index_parameters table to CSV."""
from __future__ import annotations
import argparse
import csv
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from config.settings import AppConfig
from database.connection import DatabaseConnection
from database.operations import DatabaseOperations
FIELDS = [
"param_id",
"index_type",
"param_name",
"param_value",
"description",
"effective_from",
"effective_to",
"created_at",
"updated_at",
]
def _fetch_rows(db: DatabaseOperations, index_type: Optional[str]) -> List[Dict[str, Any]]:
base_sql = """
SELECT
param_id,
index_type,
param_name,
param_value,
description,
effective_from,
effective_to,
created_at,
updated_at
FROM billiards_dws.cfg_index_parameters
"""
args: List[Any] = []
if index_type:
base_sql += " WHERE index_type = %s"
args.append(index_type)
base_sql += " ORDER BY index_type, param_name, effective_from, param_id"
rows = db.query(base_sql, args if args else None)
return [dict(r) for r in (rows or [])]
def _write_csv(rows: List[Dict[str, Any]], out_csv: Path) -> None:
out_csv.parent.mkdir(parents=True, exist_ok=True)
with out_csv.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for row in rows:
writer.writerow({k: row.get(k) for k in FIELDS})
def main() -> None:
parser = argparse.ArgumentParser(description="Export cfg_index_parameters to CSV.")
parser.add_argument(
"--index-type",
default=None,
help="Optional index type filter (e.g. RECALL, INTIMACY, NCI, WBI).",
)
parser.add_argument(
"--output-csv",
default=os.path.join(ROOT, "docs", "cfg_index_parameters.csv"),
help="Output CSV path.",
)
args = parser.parse_args()
config = AppConfig.load()
db_conn = DatabaseConnection(config.config["db"]["dsn"])
db = DatabaseOperations(db_conn)
try:
rows = _fetch_rows(db, args.index_type)
out_csv = Path(args.output_csv)
_write_csv(rows, out_csv)
print(f"rows={len(rows)}")
print(f"csv={out_csv}")
finally:
db_conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,423 @@
# -*- coding: utf-8 -*-
"""Export groupbuy orders that used assistant services."""
from __future__ import annotations
import argparse
import csv
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from config.settings import AppConfig
from database.connection import DatabaseConnection
from database.operations import DatabaseOperations
def _as_int(v: Any) -> Optional[int]:
if v is None or str(v).strip() == "":
return None
return int(v)
def _resolve_site_id(config: AppConfig, db: DatabaseOperations, cli_site_id: Optional[int]) -> int:
if cli_site_id is not None:
return int(cli_site_id)
from_cfg = _as_int(config.get("app.store_id"))
if from_cfg is not None:
return from_cfg
rows = db.query(
"""
SELECT site_id
FROM billiards_dwd.dwd_settlement_head
WHERE site_id IS NOT NULL
GROUP BY site_id
ORDER BY COUNT(*) DESC
LIMIT 1
"""
)
if rows:
return int(dict(rows[0])["site_id"])
raise RuntimeError("Unable to resolve site_id; pass --site-id explicitly.")
FIELD_ORDER: List[str] = [
"site_id",
"order_settle_id",
"order_trade_no",
"pay_time",
"settle_type",
"member_id",
"member_name",
"member_phone",
"table_id",
"table_name",
"table_area_name",
"settle_consume_money",
"settle_pay_amount",
"settle_coupon_amount",
"pl_coupon_sale_amount",
"groupbuy_item_count",
"groupbuy_pay_amount",
"groupbuy_ledger_amount",
"groupbuy_coupon_money",
"coupon_codes",
"groupbuy_items",
"assistant_service_count",
"assistant_count",
"assistant_nicknames",
"assistant_skills",
"assistant_real_use_seconds",
"assistant_projected_income",
"assistant_real_service_money",
]
ZH_HEADER_MAP: Dict[str, str] = {
"site_id": "门店ID",
"order_settle_id": "结账单ID",
"order_trade_no": "订单交易号",
"pay_time": "结账时间",
"settle_type": "结账类型",
"member_id": "会员ID",
"member_name": "会员姓名",
"member_phone": "会员手机号",
"table_id": "台桌ID",
"table_name": "台桌名称",
"table_area_name": "台区名称",
"settle_consume_money": "结算消费金额",
"settle_pay_amount": "结算实付金额",
"settle_coupon_amount": "结算团购抵扣金额",
"pl_coupon_sale_amount": "平台团购实付金额",
"groupbuy_item_count": "团购核销条目数",
"groupbuy_pay_amount": "团购实付合计",
"groupbuy_ledger_amount": "团购标价合计",
"groupbuy_coupon_money": "团购券面额合计",
"coupon_codes": "团购券码列表",
"groupbuy_items": "团购项目列表",
"assistant_service_count": "助教服务条目数",
"assistant_count": "助教人数",
"assistant_nicknames": "助教昵称列表",
"assistant_skills": "助教技能列表",
"assistant_real_use_seconds": "助教实际服务秒数",
"assistant_projected_income": "助教预计收入合计",
"assistant_real_service_money": "助教实收服务费合计",
}
def _fetch_rows_current(
db: DatabaseOperations,
site_id: int,
start_date: Optional[str],
end_date: Optional[str],
) -> List[Dict[str, Any]]:
sql = """
WITH gb AS (
SELECT
site_id,
order_settle_id,
COUNT(*) AS groupbuy_item_count,
ROUND(SUM(COALESCE(ledger_unit_price, 0))::numeric, 2) AS groupbuy_pay_amount,
ROUND(SUM(COALESCE(ledger_amount, 0))::numeric, 2) AS groupbuy_ledger_amount,
ROUND(SUM(COALESCE(coupon_money, 0))::numeric, 2) AS groupbuy_coupon_money,
STRING_AGG(DISTINCT NULLIF(coupon_code, ''), '?' ORDER BY NULLIF(coupon_code, '')) AS coupon_codes,
STRING_AGG(DISTINCT NULLIF(ledger_name, ''), '?' ORDER BY NULLIF(ledger_name, '')) AS groupbuy_items
FROM billiards_dwd.dwd_groupbuy_redemption
WHERE site_id = %s
AND is_delete = 0
GROUP BY site_id, order_settle_id
),
asv AS (
SELECT
site_id,
order_settle_id,
COUNT(*) AS assistant_service_count,
COUNT(DISTINCT NULLIF(assistant_no, '')) AS assistant_count,
STRING_AGG(DISTINCT NULLIF(nickname, ''), '?' ORDER BY NULLIF(nickname, '')) AS assistant_nicknames,
STRING_AGG(DISTINCT NULLIF(skill_name, ''), '?' ORDER BY NULLIF(skill_name, '')) AS assistant_skills,
ROUND(SUM(COALESCE(real_use_seconds, 0))::numeric, 0) AS assistant_real_use_seconds,
ROUND(SUM(COALESCE(projected_income, 0))::numeric, 2) AS assistant_projected_income,
ROUND(SUM(COALESCE(real_service_money, 0))::numeric, 2) AS assistant_real_service_money
FROM billiards_dwd.dwd_assistant_service_log
WHERE site_id = %s
AND is_delete = 0
GROUP BY site_id, order_settle_id
)
SELECT
sh.site_id,
sh.order_settle_id,
sh.order_trade_no,
sh.pay_time,
sh.settle_type,
sh.member_id,
COALESCE(dm.nickname, sh.member_name) AS member_name,
COALESCE(dm.mobile, sh.member_phone) AS member_phone,
sh.table_id,
dt.table_name,
dt.site_table_area_name AS table_area_name,
ROUND(COALESCE(sh.consume_money, 0)::numeric, 2) AS settle_consume_money,
ROUND(COALESCE(sh.pay_amount, 0)::numeric, 2) AS settle_pay_amount,
ROUND(COALESCE(sh.coupon_amount, 0)::numeric, 2) AS settle_coupon_amount,
ROUND(COALESCE(sh.pl_coupon_sale_amount, 0)::numeric, 2) AS pl_coupon_sale_amount,
gb.groupbuy_item_count,
gb.groupbuy_pay_amount,
gb.groupbuy_ledger_amount,
gb.groupbuy_coupon_money,
gb.coupon_codes,
gb.groupbuy_items,
asv.assistant_service_count,
asv.assistant_count,
asv.assistant_nicknames,
asv.assistant_skills,
asv.assistant_real_use_seconds,
asv.assistant_projected_income,
asv.assistant_real_service_money
FROM gb
JOIN asv
ON asv.site_id = gb.site_id
AND asv.order_settle_id = gb.order_settle_id
LEFT JOIN billiards_dwd.dwd_settlement_head sh
ON sh.site_id = gb.site_id
AND sh.order_settle_id = gb.order_settle_id
LEFT JOIN billiards_dwd.dim_member dm
ON dm.register_site_id = sh.site_id
AND dm.member_id = sh.member_id
AND dm.scd2_is_current = 1
LEFT JOIN billiards_dwd.dim_table dt
ON dt.site_id = sh.site_id
AND dt.table_id = sh.table_id
AND dt.scd2_is_current = 1
WHERE (%s::date IS NULL OR sh.pay_time::date >= %s::date)
AND (%s::date IS NULL OR sh.pay_time::date <= %s::date)
ORDER BY sh.pay_time DESC, sh.order_settle_id DESC
"""
rows = db.query(
sql,
(
site_id,
site_id,
start_date,
start_date,
end_date,
end_date,
),
)
return [dict(r) for r in (rows or [])]
def _fetch_rows_optimized(
db: DatabaseOperations,
site_id: int,
start_date: Optional[str],
end_date: Optional[str],
) -> List[Dict[str, Any]]:
"""
Optimized export strategy:
- Deduplicate groupbuy rows by (order_settle_id, coupon_key) to handle retry noise.
- Deduplicate assistant rows by assistant_service_id.
- Keep output schema identical to current export for direct comparison.
"""
sql = """
WITH gb_raw AS (
SELECT
redemption_id,
site_id,
order_settle_id,
order_coupon_id,
coupon_code,
ledger_name,
COALESCE(ledger_unit_price, 0) AS ledger_unit_price,
COALESCE(ledger_amount, 0) AS ledger_amount,
COALESCE(coupon_money, 0) AS coupon_money,
create_time,
COALESCE(NULLIF(coupon_code, ''), CAST(order_coupon_id AS varchar), CAST(redemption_id AS varchar)) AS coupon_key,
ROW_NUMBER() OVER (
PARTITION BY site_id, order_settle_id,
COALESCE(NULLIF(coupon_code, ''), CAST(order_coupon_id AS varchar), CAST(redemption_id AS varchar))
ORDER BY create_time DESC NULLS LAST, redemption_id DESC
) AS rn
FROM billiards_dwd.dwd_groupbuy_redemption
WHERE site_id = %s
AND is_delete = 0
),
gb AS (
SELECT
site_id,
order_settle_id,
COUNT(*) AS groupbuy_item_count,
ROUND(SUM(ledger_unit_price)::numeric, 2) AS groupbuy_pay_amount,
ROUND(SUM(ledger_amount)::numeric, 2) AS groupbuy_ledger_amount,
ROUND(SUM(coupon_money)::numeric, 2) AS groupbuy_coupon_money,
STRING_AGG(DISTINCT NULLIF(coupon_code, ''), '?' ORDER BY NULLIF(coupon_code, '')) AS coupon_codes,
STRING_AGG(DISTINCT NULLIF(ledger_name, ''), '?' ORDER BY NULLIF(ledger_name, '')) AS groupbuy_items
FROM gb_raw
WHERE rn = 1
GROUP BY site_id, order_settle_id
),
asv_raw AS (
SELECT DISTINCT ON (assistant_service_id)
assistant_service_id,
site_id,
order_settle_id,
assistant_no,
nickname,
skill_name,
COALESCE(real_use_seconds, 0) AS real_use_seconds,
COALESCE(projected_income, 0) AS projected_income,
COALESCE(real_service_money, 0) AS real_service_money
FROM billiards_dwd.dwd_assistant_service_log
WHERE site_id = %s
AND is_delete = 0
ORDER BY assistant_service_id
),
asv AS (
SELECT
site_id,
order_settle_id,
COUNT(*) AS assistant_service_count,
COUNT(DISTINCT NULLIF(assistant_no, '')) AS assistant_count,
STRING_AGG(DISTINCT NULLIF(nickname, ''), '?' ORDER BY NULLIF(nickname, '')) AS assistant_nicknames,
STRING_AGG(DISTINCT NULLIF(skill_name, ''), '?' ORDER BY NULLIF(skill_name, '')) AS assistant_skills,
ROUND(SUM(real_use_seconds)::numeric, 0) AS assistant_real_use_seconds,
ROUND(SUM(projected_income)::numeric, 2) AS assistant_projected_income,
ROUND(SUM(real_service_money)::numeric, 2) AS assistant_real_service_money
FROM asv_raw
GROUP BY site_id, order_settle_id
)
SELECT
sh.site_id,
sh.order_settle_id,
sh.order_trade_no,
sh.pay_time,
sh.settle_type,
sh.member_id,
COALESCE(dm.nickname, sh.member_name) AS member_name,
COALESCE(dm.mobile, sh.member_phone) AS member_phone,
sh.table_id,
dt.table_name,
dt.site_table_area_name AS table_area_name,
ROUND(COALESCE(sh.consume_money, 0)::numeric, 2) AS settle_consume_money,
ROUND(COALESCE(sh.pay_amount, 0)::numeric, 2) AS settle_pay_amount,
ROUND(COALESCE(sh.coupon_amount, 0)::numeric, 2) AS settle_coupon_amount,
ROUND(COALESCE(sh.pl_coupon_sale_amount, 0)::numeric, 2) AS pl_coupon_sale_amount,
gb.groupbuy_item_count,
gb.groupbuy_pay_amount,
gb.groupbuy_ledger_amount,
gb.groupbuy_coupon_money,
gb.coupon_codes,
gb.groupbuy_items,
asv.assistant_service_count,
asv.assistant_count,
asv.assistant_nicknames,
asv.assistant_skills,
asv.assistant_real_use_seconds,
asv.assistant_projected_income,
asv.assistant_real_service_money
FROM gb
JOIN asv
ON asv.site_id = gb.site_id
AND asv.order_settle_id = gb.order_settle_id
LEFT JOIN billiards_dwd.dwd_settlement_head sh
ON sh.site_id = gb.site_id
AND sh.order_settle_id = gb.order_settle_id
LEFT JOIN billiards_dwd.dim_member dm
ON dm.register_site_id = sh.site_id
AND dm.member_id = sh.member_id
AND dm.scd2_is_current = 1
LEFT JOIN billiards_dwd.dim_table dt
ON dt.site_id = sh.site_id
AND dt.table_id = sh.table_id
AND dt.scd2_is_current = 1
WHERE (%s::date IS NULL OR sh.pay_time::date >= %s::date)
AND (%s::date IS NULL OR sh.pay_time::date <= %s::date)
ORDER BY sh.pay_time DESC, sh.order_settle_id DESC
"""
rows = db.query(
sql,
(
site_id,
site_id,
start_date,
start_date,
end_date,
end_date,
),
)
return [dict(r) for r in (rows or [])]
def _write_csv(
rows: List[Dict[str, Any]],
out_csv: Path,
fields: Sequence[str],
header_map: Optional[Dict[str, str]] = None,
) -> None:
out_csv.parent.mkdir(parents=True, exist_ok=True)
if header_map:
file_headers = [header_map.get(f, f) for f in fields]
else:
file_headers = list(fields)
with out_csv.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(file_headers)
for row in rows:
writer.writerow([row.get(k) for k in fields])
def main() -> None:
parser = argparse.ArgumentParser(
description="Export groupbuy orders that used assistant services."
)
parser.add_argument("--site-id", type=int, default=None, help="Site id to export")
parser.add_argument("--start-date", default=None, help="Filter start date: YYYY-MM-DD")
parser.add_argument("--end-date", default=None, help="Filter end date: YYYY-MM-DD")
parser.add_argument(
"--scheme",
choices=["current", "optimized"],
default="current",
help="Export scheme",
)
parser.add_argument(
"--header-lang",
choices=["zh", "en"],
default="zh",
help="CSV header language",
)
parser.add_argument(
"--output-csv",
default=os.path.join(ROOT, "docs", "groupbuy_orders_with_assistant_service.csv"),
help="Output CSV path",
)
args = parser.parse_args()
config = AppConfig.load()
db_conn = DatabaseConnection(config.config["db"]["dsn"])
db = DatabaseOperations(db_conn)
try:
site_id = _resolve_site_id(config, db, args.site_id)
if args.scheme == "optimized":
rows = _fetch_rows_optimized(db, site_id, args.start_date, args.end_date)
else:
rows = _fetch_rows_current(db, site_id, args.start_date, args.end_date)
finally:
db_conn.close()
out_csv = Path(args.output_csv)
header_map = ZH_HEADER_MAP if args.header_lang == "zh" else None
_write_csv(rows, out_csv, fields=FIELD_ORDER, header_map=header_map)
print(f"site_id={site_id}")
print(f"scheme={args.scheme}")
print(f"rows={len(rows)}")
print(f"csv={out_csv}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""Export index tables to markdown for quick review."""
import os
import sys
from datetime import datetime
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from config.settings import AppConfig
from database.connection import DatabaseConnection
from database.operations import DatabaseOperations
def _fmt(value, digits=2):
if value is None:
return "-"
if isinstance(value, (int, float)):
return f"{value:.{digits}f}"
return str(value)
def _fetch(db: DatabaseOperations, sql: str):
return [dict(r) for r in (db.query(sql) or [])]
def build_markdown(db: DatabaseOperations) -> str:
lines = []
lines.append("# Index Tables")
lines.append("")
lines.append(f"Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
# 老客挽回指数WBI
wbi_sql = """
SELECT
COALESCE(m.nickname, CONCAT('member_', r.member_id)) AS member_name,
r.display_score,
r.raw_score,
r.t_v,
r.visits_14d,
r.sv_balance
FROM billiards_dws.dws_member_winback_index r
LEFT JOIN billiards_dwd.dim_member m
ON r.member_id = m.member_id AND m.scd2_is_current = 1
ORDER BY r.display_score DESC NULLS LAST
"""
wbi_rows = _fetch(db, wbi_sql)
lines.append("## 1) WBI")
lines.append("")
lines.append("| member_name | wbi | raw_score | t_v | visits_14d | sv_balance |")
lines.append("|---|---:|---:|---:|---:|---:|")
for r in wbi_rows:
lines.append(
f"| {r.get('member_name') or '-'} | {_fmt(r.get('display_score'))} | {_fmt(r.get('raw_score'), 4)} | "
f"{_fmt(r.get('t_v'))} | {_fmt(r.get('visits_14d'), 0)} | {_fmt(r.get('sv_balance'))} |"
)
lines.append("")
lines.append(f"Total rows: {len(wbi_rows)}")
lines.append("")
# 新客转化指数NCI
nci_sql = """
SELECT
COALESCE(m.nickname, CONCAT('member_', r.member_id)) AS member_name,
r.display_score,
r.display_score_welcome,
r.display_score_convert,
r.raw_score,
r.raw_score_welcome,
r.raw_score_convert,
r.t_v,
r.visits_14d
FROM billiards_dws.dws_member_newconv_index r
LEFT JOIN billiards_dwd.dim_member m
ON r.member_id = m.member_id AND m.scd2_is_current = 1
ORDER BY r.display_score DESC NULLS LAST
"""
nci_rows = _fetch(db, nci_sql)
lines.append("## 2) NCI")
lines.append("")
lines.append("| member_name | nci | welcome | convert | raw_total | raw_welcome | raw_convert | t_v | visits_14d |")
lines.append("|---|---:|---:|---:|---:|---:|---:|---:|---:|")
for r in nci_rows:
lines.append(
f"| {r.get('member_name') or '-'} | {_fmt(r.get('display_score'))} | {_fmt(r.get('display_score_welcome'))} | "
f"{_fmt(r.get('display_score_convert'))} | {_fmt(r.get('raw_score'), 4)} | {_fmt(r.get('raw_score_welcome'), 4)} | "
f"{_fmt(r.get('raw_score_convert'), 4)} | {_fmt(r.get('t_v'))} | {_fmt(r.get('visits_14d'), 0)} |"
)
lines.append("")
lines.append(f"Total rows: {len(nci_rows)}")
lines.append("")
# 亲密指数
intimacy_sql = """
SELECT
COALESCE(a.nickname, CONCAT('assistant_', i.assistant_id)) AS assistant_name,
COALESCE(m.nickname, CONCAT('member_', i.member_id)) AS member_name,
i.display_score,
i.session_count,
i.attributed_recharge_amount
FROM billiards_dws.dws_member_assistant_intimacy i
LEFT JOIN billiards_dwd.dim_member m
ON i.member_id = m.member_id AND m.scd2_is_current = 1
LEFT JOIN billiards_dwd.dim_assistant a
ON i.assistant_id = a.assistant_id AND a.scd2_is_current = 1
ORDER BY i.display_score DESC NULLS LAST, i.session_count DESC
"""
intimacy_rows = _fetch(db, intimacy_sql)
lines.append("## 3) Intimacy")
lines.append("")
lines.append("| assistant | member | intimacy | sessions | recharge_amount |")
lines.append("|---|---|---:|---:|---:|")
for r in intimacy_rows:
lines.append(
f"| {r.get('assistant_name') or '-'} | {r.get('member_name') or '-'} | {_fmt(r.get('display_score'))} | "
f"{_fmt(r.get('session_count'), 0)} | {_fmt(r.get('attributed_recharge_amount'))} |"
)
lines.append("")
lines.append(f"Total rows: {len(intimacy_rows)}")
return "\n".join(lines)
def main() -> None:
config = AppConfig.load()
db_conn = DatabaseConnection(config.config["db"]["dsn"])
db = DatabaseOperations(db_conn)
try:
markdown = build_markdown(db)
finally:
db_conn.close()
output_path = os.path.join(ROOT, "docs", "index_tables.md")
with open(output_path, "w", encoding="utf-8-sig") as f:
f.write(markdown)
print(f"Exported to {output_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,475 @@
# -*- coding: utf-8 -*-
"""Export full intimacy JSON with member visits and card balances."""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from config.settings import AppConfig
from database.connection import DatabaseConnection
from database.operations import DatabaseOperations
def _as_int(v: Any) -> Optional[int]:
if v is None:
return None
s = str(v).strip()
if not s:
return None
return int(s)
def _to_float(v: Any, default: float = 0.0) -> float:
if v is None:
return default
if isinstance(v, Decimal):
return float(v)
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip()
if not s:
return default
return float(s)
def _fmt_dt(v: Any) -> Optional[str]:
if v is None:
return None
if isinstance(v, datetime):
return v.isoformat()
if isinstance(v, date):
return v.isoformat()
return str(v)
def _resolve_site_id(config: AppConfig, db: DatabaseOperations, cli_site_id: Optional[int]) -> int:
if cli_site_id is not None:
return int(cli_site_id)
from_cfg = _as_int(config.get("app.store_id")) or _as_int(config.get("app.default_site_id"))
if from_cfg is not None:
return from_cfg
rows = db.query(
"""
SELECT site_id
FROM billiards_dws.dws_member_assistant_intimacy
WHERE site_id IS NOT NULL
GROUP BY site_id
ORDER BY COUNT(*) DESC
LIMIT 1
"""
)
if rows:
return int(dict(rows[0])["site_id"])
raise RuntimeError("Unable to resolve site_id; pass --site-id explicitly.")
def _fetch_pairs(db: DatabaseOperations, site_id: int) -> List[Dict[str, Any]]:
sql = """
SELECT
i.site_id,
i.tenant_id,
i.member_id,
i.assistant_id,
i.session_count,
i.total_duration_minutes,
i.basic_session_count,
i.incentive_session_count,
i.days_since_last_session,
i.attributed_recharge_count,
i.attributed_recharge_amount,
i.score_frequency,
i.score_recency,
i.score_recharge,
i.score_duration,
i.burst_multiplier,
i.raw_score,
i.display_score,
i.calc_time,
COALESCE(m.nickname, CONCAT('member_', i.member_id::text)) AS member_nickname,
COALESCE(a.nickname, CONCAT('assistant_', i.assistant_id::text)) AS assistant_nickname
FROM billiards_dws.dws_member_assistant_intimacy i
LEFT JOIN billiards_dwd.dim_member m
ON i.member_id = m.member_id
AND m.scd2_is_current = 1
LEFT JOIN billiards_dwd.dim_assistant a
ON i.assistant_id = a.assistant_id
AND a.scd2_is_current = 1
WHERE i.site_id = %s
ORDER BY i.display_score DESC NULLS LAST, i.session_count DESC, i.member_id, i.assistant_id
"""
rows = db.query(sql, (site_id,))
return [dict(r) for r in (rows or [])]
def _fetch_member_cards(
db: DatabaseOperations,
site_id: int,
member_ids: List[int],
) -> Dict[int, Dict[str, Any]]:
if not member_ids:
return {}
member_ids_str = ",".join(str(int(x)) for x in sorted(set(member_ids)))
sql = f"""
SELECT
tenant_member_id AS member_id,
member_card_id,
card_type_id,
member_card_grade_code,
member_card_grade_code_name,
member_card_type_name,
member_name,
member_mobile,
balance,
principal_balance,
status,
start_time,
end_time,
last_consume_time
FROM billiards_dwd.dim_member_card_account
WHERE register_site_id = %s
AND scd2_is_current = 1
AND COALESCE(is_delete, 0) = 0
AND tenant_member_id IN ({member_ids_str})
ORDER BY tenant_member_id, balance DESC NULLS LAST, member_card_id
"""
rows = db.query(sql, (site_id,)) or []
result: Dict[int, Dict[str, Any]] = {}
for r in rows:
d = dict(r)
mid = int(d["member_id"])
balance = _to_float(d.get("balance"), 0.0)
card = {
"member_card_id": _as_int(d.get("member_card_id")),
"card_type_id": _as_int(d.get("card_type_id")),
"member_card_grade_code": _as_int(d.get("member_card_grade_code")),
"member_card_grade_code_name": d.get("member_card_grade_code_name"),
"member_card_type_name": d.get("member_card_type_name"),
"member_name": d.get("member_name"),
"member_mobile": d.get("member_mobile"),
"balance": round(balance, 2),
"principal_balance": round(_to_float(d.get("principal_balance"), 0.0), 2),
"status": _as_int(d.get("status")),
"start_time": _fmt_dt(d.get("start_time")),
"end_time": _fmt_dt(d.get("end_time")),
"last_consume_time": _fmt_dt(d.get("last_consume_time")),
}
bucket = result.setdefault(
mid,
{
"member_id": mid,
"cards_all": [],
"cards_balance_ge_10": [],
"total_card_balance_all": 0.0,
},
)
bucket["cards_all"].append(card)
bucket["total_card_balance_all"] = round(bucket["total_card_balance_all"] + balance, 2)
if balance >= 10.0:
bucket["cards_balance_ge_10"].append(card)
return result
def _fetch_visit_rows(
db: DatabaseOperations,
site_id: int,
member_ids: List[int],
) -> Dict[Tuple[int, int], Dict[str, Any]]:
if not member_ids:
return {}
member_ids_str = ",".join(str(int(x)) for x in sorted(set(member_ids)))
sql = f"""
SELECT
member_id,
order_settle_id,
visit_date,
visit_time,
table_name,
area_name,
area_category,
table_duration_min,
assistant_duration_min,
table_fee,
goods_amount,
assistant_amount,
total_consume,
total_discount,
actual_pay,
cash_pay,
cash_card_pay,
gift_card_pay,
groupbuy_pay
FROM billiards_dws.dws_member_visit_detail
WHERE site_id = %s
AND member_id IN ({member_ids_str})
ORDER BY member_id, visit_time DESC, order_settle_id DESC
"""
rows = db.query(sql, (site_id,)) or []
result: Dict[Tuple[int, int], Dict[str, Any]] = {}
for r in rows:
d = dict(r)
key = (int(d["member_id"]), int(d["order_settle_id"]))
result[key] = {
"member_id": int(d["member_id"]),
"order_settle_id": int(d["order_settle_id"]),
"visit_date": _fmt_dt(d.get("visit_date")),
"visit_time": _fmt_dt(d.get("visit_time")),
"table_name": d.get("table_name"),
"area_name": d.get("area_name"),
"area_category": d.get("area_category"),
"table_duration_min": _as_int(d.get("table_duration_min")) or 0,
"assistant_duration_min_total": _as_int(d.get("assistant_duration_min")) or 0,
"table_fee": round(_to_float(d.get("table_fee"), 0.0), 2),
"goods_amount": round(_to_float(d.get("goods_amount"), 0.0), 2),
"assistant_amount": round(_to_float(d.get("assistant_amount"), 0.0), 2),
"total_consume": round(_to_float(d.get("total_consume"), 0.0), 2),
"total_discount": round(_to_float(d.get("total_discount"), 0.0), 2),
"actual_pay": round(_to_float(d.get("actual_pay"), 0.0), 2),
"cash_pay": round(_to_float(d.get("cash_pay"), 0.0), 2),
"cash_card_pay": round(_to_float(d.get("cash_card_pay"), 0.0), 2),
"gift_card_pay": round(_to_float(d.get("gift_card_pay"), 0.0), 2),
"groupbuy_pay": round(_to_float(d.get("groupbuy_pay"), 0.0), 2),
}
return result
def _fetch_assistant_service_rows(
db: DatabaseOperations,
site_id: int,
member_ids: List[int],
) -> Dict[Tuple[int, int], List[Dict[str, Any]]]:
if not member_ids:
return {}
member_ids_str = ",".join(str(int(x)) for x in sorted(set(member_ids)))
sql = f"""
SELECT
s.tenant_member_id AS member_id,
s.order_settle_id,
d.assistant_id,
COALESCE(d.nickname, s.nickname) AS assistant_nickname,
SUM(COALESCE(s.income_seconds, 0)) / 60.0 AS duration_min,
SUM(COALESCE(s.ledger_amount, 0)) AS amount
FROM billiards_dwd.dwd_assistant_service_log s
JOIN billiards_dwd.dim_assistant d
ON s.user_id = d.user_id
AND d.scd2_is_current = 1
WHERE s.site_id = %s
AND s.is_delete = 0
AND s.tenant_member_id IN ({member_ids_str})
AND s.order_settle_id IS NOT NULL
GROUP BY
s.tenant_member_id,
s.order_settle_id,
d.assistant_id,
COALESCE(d.nickname, s.nickname)
ORDER BY s.tenant_member_id, s.order_settle_id
"""
rows = db.query(sql, (site_id,)) or []
result: Dict[Tuple[int, int], List[Dict[str, Any]]] = {}
for r in rows:
d = dict(r)
key = (int(d["member_id"]), int(d["order_settle_id"]))
rec = {
"assistant_id": int(d["assistant_id"]),
"assistant_nickname": d.get("assistant_nickname"),
"duration_min": round(_to_float(d.get("duration_min"), 0.0), 2),
"amount": round(_to_float(d.get("amount"), 0.0), 2),
}
result.setdefault(key, []).append(rec)
return result
def _pk_key(assistant_nickname: Optional[str], member_nickname: Optional[str]) -> str:
a = (assistant_nickname or "").strip() or "assistant_unknown"
m = (member_nickname or "").strip() or "member_unknown"
return f"{a}__{m}"
def build_export_payload(db: DatabaseOperations, site_id: int) -> Dict[str, Any]:
pairs = _fetch_pairs(db, site_id)
member_ids = sorted({int(p["member_id"]) for p in pairs})
cards_by_member = _fetch_member_cards(db, site_id, member_ids)
visits_by_key = _fetch_visit_rows(db, site_id, member_ids)
service_by_key = _fetch_assistant_service_rows(db, site_id, member_ids)
visits_by_member: Dict[int, List[Tuple[Tuple[int, int], Dict[str, Any]]]] = {}
for k, v in visits_by_key.items():
visits_by_member.setdefault(k[0], []).append((k, v))
data_by_pk: Dict[str, Dict[str, Any]] = {}
collisions: List[str] = []
for p in pairs:
member_id = int(p["member_id"])
assistant_id = int(p["assistant_id"])
assistant_nickname = p.get("assistant_nickname")
member_nickname = p.get("member_nickname")
visit_items: List[Dict[str, Any]] = []
for key, visit in visits_by_member.get(member_id, []):
service_list = service_by_key.get(key, [])
if not service_list:
continue
matched = [x for x in service_list if x["assistant_id"] == assistant_id]
if not matched:
continue
matched_duration = round(sum(x["duration_min"] for x in matched), 2)
matched_amount = round(sum(x["amount"] for x in matched), 2)
matched_nicknames = sorted({x.get("assistant_nickname") for x in matched if x.get("assistant_nickname")})
visit_items.append(
{
"order_settle_id": visit.get("order_settle_id"),
"visit_date": visit.get("visit_date"),
"visit_time": visit.get("visit_time"),
"table_name": visit.get("table_name"),
"area_name": visit.get("area_name"),
"area_category": visit.get("area_category"),
"table_duration_min": visit.get("table_duration_min"),
"assistant_duration_min_total": visit.get("assistant_duration_min_total"),
"table_fee": visit.get("table_fee"),
"goods_amount": visit.get("goods_amount"),
"assistant_amount": visit.get("assistant_amount"),
"total_consume": visit.get("total_consume"),
"total_discount": visit.get("total_discount"),
"actual_pay": visit.get("actual_pay"),
"cash_pay": visit.get("cash_pay"),
"cash_card_pay": visit.get("cash_card_pay"),
"gift_card_pay": visit.get("gift_card_pay"),
"groupbuy_pay": visit.get("groupbuy_pay"),
"target_assistant_nickname": ", ".join(matched_nicknames) if matched_nicknames else p.get("assistant_nickname"),
"target_assistant_duration_min": matched_duration,
"target_assistant_amount": matched_amount,
}
)
visit_items.sort(
key=lambda x: (x.get("visit_time") or "", x.get("order_settle_id") or 0),
reverse=True,
)
member_cards = cards_by_member.get(
member_id,
{
"member_id": member_id,
"cards_all": [],
"cards_balance_ge_10": [],
"total_card_balance_all": 0.0,
},
)
pk = _pk_key(assistant_nickname, member_nickname)
item = {
"primary_key": {
"assistant_nickname": assistant_nickname,
"member_nickname": member_nickname,
},
"intimacy": {
"display_score": round(_to_float(p.get("display_score"), 0.0), 2),
"raw_score": round(_to_float(p.get("raw_score"), 0.0), 6),
"session_count": _as_int(p.get("session_count")) or 0,
"total_duration_minutes": _as_int(p.get("total_duration_minutes")) or 0,
"basic_session_count": _as_int(p.get("basic_session_count")) or 0,
"incentive_session_count": _as_int(p.get("incentive_session_count")) or 0,
"days_since_last_session": _as_int(p.get("days_since_last_session")),
"attributed_recharge_count": _as_int(p.get("attributed_recharge_count")) or 0,
"attributed_recharge_amount": round(_to_float(p.get("attributed_recharge_amount"), 0.0), 2),
"score_frequency": round(_to_float(p.get("score_frequency"), 0.0), 4),
"score_recency": round(_to_float(p.get("score_recency"), 0.0), 4),
"score_recharge": round(_to_float(p.get("score_recharge"), 0.0), 4),
"score_duration": round(_to_float(p.get("score_duration"), 0.0), 4),
"burst_multiplier": round(_to_float(p.get("burst_multiplier"), 1.0), 4),
"calc_time": _fmt_dt(p.get("calc_time")),
},
"member_cards": {
"cards_balance_ge_10": member_cards.get("cards_balance_ge_10", []),
"total_card_balance_all": round(_to_float(member_cards.get("total_card_balance_all"), 0.0), 2),
},
"visit_consumptions": visit_items,
}
if pk in data_by_pk:
collisions.append(pk)
existing = data_by_pk[pk]
existing["collision_items"] = existing.get("collision_items", [])
existing["collision_items"].append(item)
else:
data_by_pk[pk] = item
payload = {
"meta": {
"site_id": site_id,
"generated_at": datetime.now().isoformat(),
"pair_count": len(pairs),
"primary_key_count": len(data_by_pk),
"member_count": len(member_ids),
"primary_key_rule": "assistant_nickname + member_nickname",
"collision_count": len(collisions),
},
"data": data_by_pk,
}
return payload
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Export full intimacy JSON")
parser.add_argument("--site-id", type=int, default=None, help="site_id, defaults to app.store_id")
parser.add_argument(
"--output",
default="tmp/intimacy_full_export.json",
help="output JSON file path",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
config = AppConfig.load()
db_conn = DatabaseConnection(config.config["db"]["dsn"])
db = DatabaseOperations(db_conn)
try:
site_id = _resolve_site_id(config, db, args.site_id)
payload = build_export_payload(db, site_id)
finally:
db_conn.close()
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"Exported intimacy JSON: {output_path}")
print(f"pair_count={payload['meta']['pair_count']}, member_count={payload['meta']['member_count']}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,720 @@
# -*- coding: utf-8 -*-
"""Export 60-day member visit detail with WBI/NCI scores."""
from __future__ import annotations
import argparse
import csv
import math
import os
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from config.settings import AppConfig
from database.connection import DatabaseConnection
from database.operations import DatabaseOperations
FIELDS = [
"site_id",
"member_id",
"member_nickname",
"visit_time",
"consume_amount",
"sv_balance",
"assistant_nicknames",
"wbi_score",
"nci_score",
]
def _as_int(v: Any) -> Optional[int]:
if v is None or str(v).strip() == "":
return None
return int(v)
def _as_float(v: Any, default: float = 0.0) -> float:
if v is None or str(v).strip() == "":
return default
return float(v)
def _resolve_site_id(config: AppConfig, db: DatabaseOperations, cli_site_id: Optional[int]) -> int:
if cli_site_id is not None:
return int(cli_site_id)
from_cfg = _as_int(config.get("app.store_id")) or _as_int(config.get("app.default_site_id"))
if from_cfg is not None:
return from_cfg
rows = db.query(
"""
SELECT site_id
FROM billiards_dwd.dwd_settlement_head
WHERE site_id IS NOT NULL
GROUP BY site_id
ORDER BY COUNT(*) DESC
LIMIT 1
"""
)
if rows:
return int(dict(rows[0])["site_id"])
raise RuntimeError("Unable to resolve site_id; pass --site-id explicitly.")
def _visit_condition_sql() -> str:
return """
(
s.settle_type = 1
OR (
s.settle_type = 3
AND EXISTS (
SELECT 1
FROM billiards_dwd.dwd_assistant_service_log asl
JOIN billiards_dws.cfg_skill_type st
ON asl.skill_id = st.skill_id
AND st.course_type_code = 'BONUS'
AND st.is_active = TRUE
WHERE asl.order_settle_id = s.order_settle_id
AND asl.site_id = s.site_id
AND asl.tenant_member_id = s.member_id
AND asl.is_delete = 0
)
)
)
"""
def _fetch_visit_rows_base(
db: DatabaseOperations,
site_id: int,
start_time: datetime,
end_time: datetime,
) -> List[Dict[str, Any]]:
sql = f"""
WITH visit_raw AS (
SELECT
s.site_id,
COALESCE(NULLIF(s.member_id, 0), mca.tenant_member_id) AS member_id,
s.order_settle_id,
s.pay_time AS visit_time,
COALESCE(s.consume_money, 0) AS consume_amount
FROM billiards_dwd.dwd_settlement_head s
LEFT JOIN billiards_dwd.dim_member_card_account mca
ON s.member_card_account_id = mca.member_card_id
AND mca.scd2_is_current = 1
AND mca.register_site_id = s.site_id
WHERE s.site_id = %s
AND s.pay_time >= %s
AND s.pay_time < %s
AND {_visit_condition_sql()}
AND COALESCE(NULLIF(s.member_id, 0), mca.tenant_member_id) > 0
),
assistant_agg AS (
SELECT
asl.order_settle_id,
STRING_AGG(DISTINCT NULLIF(asl.nickname, ''), '?' ORDER BY NULLIF(asl.nickname, '')) AS assistant_nicknames
FROM billiards_dwd.dwd_assistant_service_log asl
WHERE asl.site_id = %s
AND asl.is_delete = 0
GROUP BY asl.order_settle_id
),
member_balance AS (
SELECT
mca.register_site_id AS site_id,
mca.tenant_member_id AS member_id,
SUM(
CASE
WHEN mca.card_type_id = 2793249295533893 THEN COALESCE(mca.balance, 0)
ELSE 0
END
) AS sv_balance
FROM billiards_dwd.dim_member_card_account mca
WHERE mca.register_site_id = %s
AND mca.scd2_is_current = 1
GROUP BY mca.register_site_id, mca.tenant_member_id
),
member_name AS (
SELECT member_id, nickname
FROM billiards_dwd.dim_member
WHERE register_site_id = %s
AND scd2_is_current = 1
)
SELECT
vr.site_id,
vr.member_id,
COALESCE(mn.nickname, CONCAT('member_', vr.member_id::text)) AS member_nickname,
vr.visit_time,
ROUND(vr.consume_amount::numeric, 2) AS consume_amount,
ROUND(COALESCE(mb.sv_balance, 0)::numeric, 2) AS sv_balance,
aa.assistant_nicknames
FROM visit_raw vr
LEFT JOIN assistant_agg aa
ON aa.order_settle_id = vr.order_settle_id
LEFT JOIN member_balance mb
ON mb.site_id = vr.site_id
AND mb.member_id = vr.member_id
LEFT JOIN member_name mn
ON mn.member_id = vr.member_id
ORDER BY vr.visit_time DESC, vr.order_settle_id DESC
"""
rows = db.query(sql, (site_id, start_time, end_time, site_id, site_id, site_id))
return [dict(r) for r in (rows or [])]
def _fetch_current_score_maps(
db: DatabaseOperations,
site_id: int,
) -> Tuple[Dict[int, float], Dict[int, float]]:
wbi_rows = db.query(
"""
SELECT member_id, display_score AS wbi_score
FROM billiards_dws.dws_member_winback_index
WHERE site_id = %s
""",
(site_id,),
)
nci_rows = db.query(
"""
SELECT member_id, display_score AS nci_score
FROM billiards_dws.dws_member_newconv_index
WHERE site_id = %s
""",
(site_id,),
)
wbi_map = {
int(dict(r)["member_id"]): round(float(dict(r)["wbi_score"]), 2)
for r in (wbi_rows or [])
if dict(r).get("wbi_score") is not None
}
nci_map = {
int(dict(r)["member_id"]): round(float(dict(r)["nci_score"]), 2)
for r in (nci_rows or [])
if dict(r).get("nci_score") is not None
}
return wbi_map, nci_map
def _load_wbi_params(db: DatabaseOperations) -> Dict[str, float]:
sql = """
SELECT param_name, param_value
FROM (
SELECT
param_name,
param_value,
ROW_NUMBER() OVER (
PARTITION BY param_name
ORDER BY effective_from DESC, updated_at DESC, created_at DESC
) AS rn
FROM billiards_dws.cfg_index_parameters
WHERE index_type = 'WBI'
AND effective_from <= CURRENT_DATE
) t
WHERE rn = 1
"""
rows = db.query(sql)
params: Dict[str, float] = {}
for row in (rows or []):
d = dict(row)
params[str(d["param_name"])] = float(d["param_value"])
return params
def _fetch_wbi_member_rows(db: DatabaseOperations, site_id: int) -> Dict[int, Dict[str, Any]]:
rows = db.query(
"""
SELECT
member_id,
status,
segment,
t_v,
interval_count,
overdue_old,
drop_old,
recharge_old,
value_old,
raw_score,
display_score
FROM billiards_dws.dws_member_winback_index
WHERE site_id = %s
""",
(site_id,),
)
result: Dict[int, Dict[str, Any]] = {}
for row in (rows or []):
d = dict(row)
mid = int(d["member_id"])
result[mid] = d
return result
def _fetch_member_interval_samples(
db: DatabaseOperations,
site_id: int,
member_ids: List[int],
base_date: date,
visit_lookback_days: int,
recency_days: int,
) -> Dict[int, List[Tuple[float, int]]]:
if not member_ids:
return {}
member_ids_str = ",".join(str(m) for m in member_ids)
start_date = base_date - timedelta(days=visit_lookback_days)
sql = f"""
WITH visit_source AS (
SELECT
COALESCE(NULLIF(s.member_id, 0), mca.tenant_member_id) AS member_id,
DATE(s.pay_time) AS visit_date
FROM billiards_dwd.dwd_settlement_head s
LEFT JOIN billiards_dwd.dim_member_card_account mca
ON s.member_card_account_id = mca.member_card_id
AND mca.scd2_is_current = 1
AND mca.register_site_id = s.site_id
WHERE s.site_id = %s
AND s.pay_time >= %s
AND s.pay_time < %s + INTERVAL '1 day'
AND {_visit_condition_sql()}
AND COALESCE(NULLIF(s.member_id, 0), mca.tenant_member_id) IN ({member_ids_str})
),
visit_dedup AS (
SELECT member_id, visit_date
FROM visit_source
GROUP BY member_id, visit_date
)
SELECT member_id, visit_date
FROM visit_dedup
ORDER BY member_id, visit_date
"""
rows = db.query(sql, (site_id, start_date, base_date))
member_dates: Dict[int, List[date]] = {}
for row in (rows or []):
d = dict(row)
mid = int(d["member_id"])
vdt = d["visit_date"]
if vdt is None:
continue
member_dates.setdefault(mid, []).append(vdt)
result: Dict[int, List[Tuple[float, int]]] = {}
for mid, dates in member_dates.items():
samples: List[Tuple[float, int]] = []
for i in range(1, len(dates)):
interval = (dates[i] - dates[i - 1]).days
interval_capped = float(min(recency_days, interval))
age_days = max(0, (base_date - dates[i]).days)
samples.append((interval_capped, age_days))
result[mid] = samples
return result
def _weighted_cdf(
samples: List[Tuple[float, int]],
t_v: float,
halflife_days: float,
blend_min_samples: int = 8,
) -> float:
if not samples:
return 0.5
if halflife_days <= 0:
p_eq = sum(1.0 for x, _ in samples if x <= t_v) / len(samples)
return p_eq
ln2 = math.log(2.0)
weights: List[float] = []
indicators: List[float] = []
for interval, age_days in samples:
w = math.exp(-ln2 * float(age_days) / halflife_days)
weights.append(w)
indicators.append(1.0 if interval <= t_v else 0.0)
w_sum = sum(weights)
if w_sum <= 0:
p_w = 0.5
else:
p_w = sum(w * ind for w, ind in zip(weights, indicators)) / w_sum
p_eq = sum(indicators) / len(indicators)
m = len(samples)
lam = min(1.0, float(m) / float(max(1, blend_min_samples)))
p = lam * p_w + (1.0 - lam) * p_eq
return max(0.0, min(1.0, p))
def _calculate_percentiles(scores: List[float], lower: int, upper: int) -> Tuple[float, float]:
if not scores:
return 0.0, 0.0
sorted_scores = sorted(scores)
n = len(sorted_scores)
lower_idx = max(0, int(n * lower / 100) - 1)
upper_idx = min(n - 1, int(n * upper / 100))
return sorted_scores[lower_idx], sorted_scores[upper_idx]
def _winsorize(value: float, lower: float, upper: float) -> float:
return min(max(value, lower), upper)
def _normalize_to_display(value: float, min_val: float, max_val: float, compression_mode: str) -> float:
if compression_mode == "log1p":
value = math.log1p(value)
min_val = math.log1p(min_val)
max_val = math.log1p(max_val)
elif compression_mode == "asinh":
value = math.asinh(value)
min_val = math.asinh(min_val)
max_val = math.asinh(max_val)
eps = 1e-6
rng = max_val - min_val
if rng < eps:
return 5.0
score = 10.0 * (value - min_val) / rng
return max(0.0, min(10.0, score))
def _compression_mode_from_param(params: Dict[str, float]) -> str:
mode = int(params.get("compression_mode", 0))
if mode == 1:
return "log1p"
if mode == 2:
return "asinh"
return "none"
def _build_wbi_optimized_map(
db: DatabaseOperations,
site_id: int,
base_date: date,
half_life_days: float,
) -> Dict[int, Optional[float]]:
params = _load_wbi_params(db)
w_over = float(params.get("w_over", 2.0))
w_drop = float(params.get("w_drop", 1.0))
w_re = float(params.get("w_re", 0.4))
w_value = float(params.get("w_value", 1.2))
overdue_alpha = float(params.get("overdue_alpha", 2.0))
percentile_lower = int(params.get("percentile_lower", 5))
percentile_upper = int(params.get("percentile_upper", 95))
recency_days = int(params.get("lookback_days_recency", 60))
visit_lookback_days = int(params.get("visit_lookback_days", 180))
member_rows = _fetch_wbi_member_rows(db, site_id)
member_ids_for_calc = [
mid
for mid, row in member_rows.items()
if row.get("segment") == "OLD" and row.get("raw_score") is not None
]
interval_samples = _fetch_member_interval_samples(
db=db,
site_id=site_id,
member_ids=member_ids_for_calc,
base_date=base_date,
visit_lookback_days=visit_lookback_days,
recency_days=recency_days,
)
raw_new_map: Dict[int, float] = {}
for mid in member_ids_for_calc:
row = member_rows[mid]
t_v = _as_float(row.get("t_v"), recency_days)
overdue_old = _as_float(row.get("overdue_old"))
drop_old = _as_float(row.get("drop_old"))
recharge_old = _as_float(row.get("recharge_old"))
value_old = _as_float(row.get("value_old"))
raw_old = _as_float(row.get("raw_score"))
pre_old = (
w_over * overdue_old
+ w_drop * drop_old
+ w_re * recharge_old
+ w_value * value_old
)
if pre_old <= 1e-9:
suppression = 1.0
else:
suppression = max(0.0, min(1.0, raw_old / pre_old))
p_weighted = _weighted_cdf(
samples=interval_samples.get(mid, []),
t_v=t_v,
halflife_days=half_life_days,
)
overdue_new = math.pow(p_weighted, overdue_alpha)
pre_new = (
w_over * overdue_new
+ w_drop * drop_old
+ w_re * recharge_old
+ w_value * value_old
)
raw_new = max(0.0, pre_new * suppression)
raw_new_map[mid] = raw_new
if not raw_new_map:
return {mid: _as_float(row.get("display_score")) for mid, row in member_rows.items()}
scores = list(raw_new_map.values())
q_l, q_u = _calculate_percentiles(scores, percentile_lower, percentile_upper)
compression_mode = _compression_mode_from_param(params)
display_new_map: Dict[int, Optional[float]] = {}
for mid, raw_score in raw_new_map.items():
clipped = _winsorize(raw_score, q_l, q_u)
display = _normalize_to_display(clipped, q_l, q_u, compression_mode=compression_mode)
display_new_map[mid] = round(display, 2)
# 保留未重新计算的会员(如 STOP_HIGH_BALANCE的当前展示分数。
result: Dict[int, Optional[float]] = {}
for mid, row in member_rows.items():
if mid in display_new_map:
result[mid] = display_new_map[mid]
else:
current = row.get("display_score")
result[mid] = None if current is None else round(float(current), 2)
return result
def _attach_scores(
base_rows: List[Dict[str, Any]],
wbi_map: Dict[int, Optional[float]],
nci_map: Dict[int, float],
) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
for row in base_rows:
mid = int(row["member_id"])
new_row = {
"site_id": row.get("site_id"),
"member_id": row.get("member_id"),
"member_nickname": row.get("member_nickname"),
"visit_time": row.get("visit_time"),
"consume_amount": row.get("consume_amount"),
"sv_balance": row.get("sv_balance"),
"assistant_nicknames": row.get("assistant_nicknames"),
"wbi_score": wbi_map.get(mid),
"nci_score": nci_map.get(mid),
}
result.append(new_row)
return result
def _write_csv(rows: List[Dict[str, Any]], out_csv: Path) -> None:
out_csv.parent.mkdir(parents=True, exist_ok=True)
with out_csv.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for row in rows:
writer.writerow({k: row.get(k) for k in FIELDS})
def _write_preview_md(rows: List[Dict[str, Any]], out_md: Path, limit: int = 200) -> None:
out_md.parent.mkdir(parents=True, exist_ok=True)
lines = [
"|" + "|".join(FIELDS) + "|",
"|" + "|".join(["---"] * len(FIELDS)) + "|",
]
for row in rows[:limit]:
cells = ["" if row.get(c) is None else str(row.get(c)) for c in FIELDS]
lines.append("|" + "|".join(cells) + "|")
out_md.write_text("\n".join(lines), encoding="utf-8-sig")
def _diff_and_write_report(
current_rows: List[Dict[str, Any]],
optimized_rows: List[Dict[str, Any]],
out_md: Path,
) -> None:
def _to_map(rows: List[Dict[str, Any]]) -> Dict[Tuple[Any, Any, Any], Dict[str, Any]]:
result: Dict[Tuple[Any, Any, Any], Dict[str, Any]] = {}
for r in rows:
key = (r.get("site_id"), r.get("member_id"), r.get("visit_time"))
result[key] = r
return result
cur_map = _to_map(current_rows)
opt_map = _to_map(optimized_rows)
cur_keys = set(cur_map.keys())
opt_keys = set(opt_map.keys())
common_keys = sorted(cur_keys & opt_keys)
changed_rows = 0
changed_wbi_rows = 0
changed_nci_rows = 0
changed_member_ids = set()
member_wbi_deltas: Dict[int, List[float]] = {}
for k in common_keys:
c = cur_map[k]
o = opt_map[k]
wbi_c = c.get("wbi_score")
wbi_o = o.get("wbi_score")
nci_c = c.get("nci_score")
nci_o = o.get("nci_score")
row_changed = (wbi_c != wbi_o) or (nci_c != nci_o)
if row_changed:
changed_rows += 1
mid = int(c["member_id"])
changed_member_ids.add(mid)
if wbi_c != wbi_o:
changed_wbi_rows += 1
if wbi_c is not None and wbi_o is not None:
member_wbi_deltas.setdefault(mid, []).append(float(wbi_o) - float(wbi_c))
if nci_c != nci_o:
changed_nci_rows += 1
member_delta_summary: List[Tuple[int, float, int]] = []
for mid, ds in member_wbi_deltas.items():
if not ds:
continue
avg_delta = sum(ds) / len(ds)
member_delta_summary.append((mid, avg_delta, len(ds)))
member_delta_summary.sort(key=lambda x: abs(x[1]), reverse=True)
lines = [
"# visit_60d_member_detail_with_indices当前版 vs 优化版",
"",
"## 对比概览",
f"- 当前行数: `{len(current_rows)}`",
f"- 优化行数: `{len(optimized_rows)}`",
f"- 共同主键行数(site_id,member_id,visit_time): `{len(common_keys)}`",
f"- 仅当前有: `{len(cur_keys - opt_keys)}`",
f"- 仅优化有: `{len(opt_keys - cur_keys)}`",
f"- 分数发生变化的行: `{changed_rows}`",
f"- WBI变化行: `{changed_wbi_rows}`",
f"- NCI变化行: `{changed_nci_rows}`",
f"- 涉及会员数: `{len(changed_member_ids)}`",
"",
"## 经营解读",
"- 本次优化只改 WBI把 Overdue 从等权历史替换为时间加权CDF近期样本权重更高",
"- NCI保持不变用于避免把两类策略老客挽回/新客转化)混在一次改动里。",
"- 若变化主要出现在近期行为变化快的会员,通常更符合一线“近期状态优先”的经营直觉。",
"",
"## WBI变化最大会员(按平均分差绝对值)",
"|member_id|avg_delta(optimized-current)|visit_rows|",
"|---|---:|---:|",
]
for mid, avg_delta, cnt in member_delta_summary[:20]:
lines.append(f"|{mid}|{avg_delta:.2f}|{cnt}|")
if len(member_delta_summary) == 0:
lines.append("|(none)|0.00|0|")
out_md.parent.mkdir(parents=True, exist_ok=True)
out_md.write_text("\n".join(lines), encoding="utf-8-sig")
def main() -> None:
parser = argparse.ArgumentParser(description="Export 60-day member visit detail with WBI/NCI scores.")
parser.add_argument("--site-id", type=int, default=None, help="Site id to export")
parser.add_argument("--days", type=int, default=60, help="Lookback days (default: 60)")
parser.add_argument(
"--scheme",
choices=["current", "optimized", "both"],
default="current",
help="Export scheme",
)
parser.add_argument(
"--wbi-interval-halflife-days",
type=float,
default=30.0,
help="Half-life days for weighted CDF in optimized WBI",
)
parser.add_argument(
"--output-csv",
default=os.path.join(ROOT, "docs", "visit_60d_member_detail_with_indices.csv"),
help="Output CSV path (used by current/optimized single scheme)",
)
parser.add_argument(
"--output-preview-md",
default=os.path.join(ROOT, "docs", "visit_60d_member_detail_with_indices_preview.md"),
help="Output preview markdown path (used by current/optimized single scheme)",
)
parser.add_argument(
"--output-csv-current",
default=os.path.join(ROOT, "docs", "visit_60d_member_detail_with_indices_current.csv"),
help="Output CSV path for current scheme when --scheme both",
)
parser.add_argument(
"--output-csv-optimized",
default=os.path.join(ROOT, "docs", "visit_60d_member_detail_with_indices_optimized.csv"),
help="Output CSV path for optimized scheme when --scheme both",
)
parser.add_argument(
"--output-compare-md",
default=os.path.join(ROOT, "docs", "visit_60d_member_detail_with_indices_compare.md"),
help="Output compare markdown path when --scheme both",
)
parser.add_argument("--preview-limit", type=int, default=200, help="Preview markdown row limit")
args = parser.parse_args()
config = AppConfig.load()
db_conn = DatabaseConnection(config.config["db"]["dsn"])
db = DatabaseOperations(db_conn)
try:
site_id = _resolve_site_id(config, db, args.site_id)
now = datetime.now()
start_time = now - timedelta(days=max(1, int(args.days)))
end_time = now
base_rows = _fetch_visit_rows_base(db, site_id, start_time, end_time)
wbi_current_map, nci_current_map = _fetch_current_score_maps(db, site_id)
if args.scheme == "current":
rows = _attach_scores(base_rows, wbi_current_map, nci_current_map)
out_csv = Path(args.output_csv)
out_md = Path(args.output_preview_md)
_write_csv(rows, out_csv)
_write_preview_md(rows, out_md, limit=max(1, int(args.preview_limit)))
print(f"site_id={site_id}")
print("scheme=current")
print(f"rows={len(rows)}")
print(f"csv={out_csv}")
print(f"preview={out_md}")
return
wbi_optimized_map = _build_wbi_optimized_map(
db=db,
site_id=site_id,
base_date=end_time.date(),
half_life_days=max(1.0, float(args.wbi_interval_halflife_days)),
)
if args.scheme == "optimized":
rows = _attach_scores(base_rows, wbi_optimized_map, nci_current_map)
out_csv = Path(args.output_csv)
out_md = Path(args.output_preview_md)
_write_csv(rows, out_csv)
_write_preview_md(rows, out_md, limit=max(1, int(args.preview_limit)))
print(f"site_id={site_id}")
print("scheme=optimized")
print(f"rows={len(rows)}")
print(f"csv={out_csv}")
print(f"preview={out_md}")
return
current_rows = _attach_scores(base_rows, wbi_current_map, nci_current_map)
optimized_rows = _attach_scores(base_rows, wbi_optimized_map, nci_current_map)
out_cur = Path(args.output_csv_current)
out_opt = Path(args.output_csv_optimized)
out_cmp = Path(args.output_compare_md)
_write_csv(current_rows, out_cur)
_write_csv(optimized_rows, out_opt)
_diff_and_write_report(current_rows, optimized_rows, out_cmp)
print(f"site_id={site_id}")
print("scheme=both")
print(f"rows={len(current_rows)}")
print(f"csv_current={out_cur}")
print(f"csv_optimized={out_opt}")
print(f"compare={out_cmp}")
finally:
db_conn.close()
if __name__ == "__main__":
main()