init: 项目初始提交 - NeoZQYY Monorepo 完整代码
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
143
apps/etl/pipelines/feiqiu/scripts/export/export_index_tables.py
Normal file
143
apps/etl/pipelines/feiqiu/scripts/export/export_index_tables.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user