store_member_tier_stats_2026.py
8.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""按门店统计会员分层(截止 2025-12 ~ 2026-05 各月末,全部为累计口径)。MySQL 5.7 兼容。
累计口径:
- 总人数:截止日前已注册的有效会员(khlx=3)
- 新客:截止日前在该店有过「首次有效成交」的去重人头(历史累计,非当月)
- 常到店/预警/灰/黑客:截止日当天的分层快照(互斥)
- 退款:截止日前在该店有过退卡的去重人头(历史累计,非当月)
"""
import csv
import os
from collections import defaultdict
from datetime import date
import pymysql
DB = dict(
host="rm-2vccze142rc9a8f58bo.mysql.cn-chengdu.rds.aliyuncs.com",
user="nettest",
password="nettest",
database="lqerp",
port=3306,
charset="utf8mb4",
connect_timeout=30,
read_timeout=600,
)
# (年, 月) 列表
PERIODS = [(2025, 12), (2026, 1), (2026, 2), (2026, 3), (2026, 4), (2026, 5)]
DAYS_ACTIVE = 90
DAYS_WARN_MAX = 150
ROOT = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
OUT_CSV = os.path.join(
ROOT, "ExportFiles", "门店会员分层统计_2025年12月-2026年5月截止_累计.csv"
)
def month_end(y: int, m: int) -> date:
if m == 12:
return date(y, 12, 31)
return date(y, m + 1, 1).fromordinal(date(y, m + 1, 1).toordinal() - 1)
def classify(days_since: int, remain: float) -> str:
if days_since <= DAYS_ACTIVE:
return "active"
if days_since <= DAYS_WARN_MAX:
return "warn"
if remain > 0:
return "gray"
return "black"
def run_month(cur, cutoff: date, label: str) -> list:
cutoff_s = f"{cutoff} 23:59:59"
cur.execute(
"""
SELECT F_Id AS store_id, dm AS store_name FROM lq_mdxx
"""
)
store_names = {r["store_id"]: r["store_name"] for r in cur.fetchall()}
cur.execute(
"""
SELECT kh.F_Id AS member_id, kh.gsmd AS store_id, kh.F_CreateTime AS create_time
FROM lq_khxx kh
WHERE kh.F_IsEffective = 1 AND kh.khlx = '3'
AND kh.gsmd IS NOT NULL AND kh.gsmd <> ''
AND kh.F_CreateTime <= %s
""",
(cutoff_s,),
)
members = cur.fetchall()
if not members:
return []
member_ids = [m["member_id"] for m in members]
cur.execute(
"""
SELECT xh.hy AS member_id, MAX(xh.hksj) AS last_hksj
FROM lq_xh_hyhk xh
WHERE xh.F_IsEffective = 1 AND xh.hksj <= %s
GROUP BY xh.hy
""",
(cutoff_s,),
)
last_visit = {r["member_id"]: r["last_hksj"] for r in cur.fetchall()}
# 剩余项目次数:开单-消耗-退卡(按会员+门店)
remain_sql = """
SELECT t.member_id, t.store_id,
GREATEST(COALESCE(b.cnt,0)-COALESCE(c.cnt,0)-COALESCE(r.cnt,0), 0) AS remain_cnt
FROM (
SELECT DISTINCT m.F_Id AS member_id, m.gsmd AS store_id
FROM lq_khxx m
WHERE m.F_Id IN ({ids})
) t
LEFT JOIN (
SELECT px.F_MemberId AS member_id, kd.djmd AS store_id,
SUM(CAST(px.F_ProjectNumber AS DECIMAL(18,2))) AS cnt
FROM lq_kd_pxmx px
INNER JOIN lq_kd_kdjlb kd ON px.glkdbh = kd.F_Id
WHERE px.F_IsEffective = 1 AND kd.F_IsEffective = 1
AND kd.kdrq <= %s AND px.F_MemberId IN ({ids})
GROUP BY px.F_MemberId, kd.djmd
) b ON b.member_id = t.member_id AND b.store_id = t.store_id
LEFT JOIN (
SELECT px.F_MemberId AS member_id, xh.md AS store_id,
SUM(CAST(px.F_ProjectNumber AS DECIMAL(18,2))) AS cnt
FROM lq_xh_pxmx px
INNER JOIN lq_xh_hyhk xh ON px.F_ConsumeInfoId = xh.F_Id
WHERE px.F_IsEffective = 1 AND xh.F_IsEffective = 1
AND xh.hksj <= %s AND px.F_MemberId IN ({ids})
GROUP BY px.F_MemberId, xh.md
) c ON c.member_id = t.member_id AND c.store_id = t.store_id
LEFT JOIN (
SELECT px.F_MemberId AS member_id, hytk.md AS store_id,
SUM(CAST(mx.F_ProjectNumber AS DECIMAL(18,2))) AS cnt
FROM lq_hytk_mx mx
INNER JOIN lq_hytk_hytk hytk ON mx.F_RefundInfoId = hytk.F_Id
INNER JOIN lq_kd_pxmx px ON mx.F_BillingItemId = px.F_Id AND px.F_IsEffective = 1
WHERE mx.F_IsEffective = 1 AND hytk.F_IsEffective = 1
AND hytk.tksj <= %s AND px.F_MemberId IN ({ids})
GROUP BY px.F_MemberId, hytk.md
) r ON r.member_id = t.member_id AND r.store_id = t.store_id
"""
remain_map = {}
batch = 800
for i in range(0, len(member_ids), batch):
chunk = member_ids[i : i + batch]
ph = ",".join(["%s"] * len(chunk))
sql = remain_sql.format(ids=ph)
params = chunk + [cutoff_s] + chunk + [cutoff_s] + chunk + [cutoff_s] + chunk
cur.execute(sql, params)
for r in cur.fetchall():
remain_map[(r["member_id"], r["store_id"])] = float(r["remain_cnt"] or 0)
tier = defaultdict(
lambda: dict(
total=0, active=0, warn=0, gray=0, black=0, new=0, refund=0
)
)
for m in members:
sid = m["store_id"]
mid = m["member_id"]
lv = last_visit.get(mid)
anchor = lv if lv else m["create_time"]
if anchor is None:
days_since = 9999
else:
if hasattr(anchor, "date"):
days_since = (cutoff - anchor.date()).days
else:
days_since = (cutoff - anchor).days
rem = remain_map.get((mid, sid), 0.0)
tier[sid]["total"] += 1
cat = classify(days_since, rem)
tier[sid][cat] += 1
# 累计新客:截至 cutoff,在该店首次有效成交的去重人头
cur.execute(
"""
SELECT first_bill.store_id, COUNT(*) AS cnt
FROM (
SELECT kd.kdhy AS member_id, kd.djmd AS store_id, MIN(kd.kdrq) AS first_kdrq
FROM lq_kd_kdjlb kd
WHERE kd.F_IsEffective = 1 AND kd.sfyj > 0
AND kd.djmd IS NOT NULL AND kd.djmd <> ''
GROUP BY kd.kdhy, kd.djmd
) first_bill
WHERE first_bill.first_kdrq <= %s
GROUP BY first_bill.store_id
""",
(cutoff_s,),
)
for r in cur.fetchall():
tier[r["store_id"]]["new"] = int(r["cnt"])
# 累计退款:截至 cutoff,在该店有过退卡的去重人头
cur.execute(
"""
SELECT hytk.md AS store_id, COUNT(DISTINCT hytk.hy) AS cnt
FROM lq_hytk_hytk hytk
WHERE hytk.F_IsEffective = 1
AND hytk.tksj <= %s
AND hytk.md IS NOT NULL AND hytk.md <> ''
GROUP BY hytk.md
""",
(cutoff_s,),
)
for r in cur.fetchall():
tier[r["store_id"]]["refund"] = int(r["cnt"])
rows = []
all_store_ids = set(tier.keys()) | set(store_names.keys())
for sid in all_store_ids:
t = tier[sid]
if t["total"] == 0 and t["new"] == 0 and t["refund"] == 0:
continue
rows.append(
{
"截止月份": label,
"门店ID": sid,
"门店名称": store_names.get(sid, sid),
"总人数": t["total"],
"新客": t["new"],
"常到店": t["active"],
"预警客": t["warn"],
"灰客": t["gray"],
"黑客": t["black"],
"退款": t["refund"],
}
)
rows.sort(key=lambda x: (-x["总人数"], x["门店名称"]))
return rows
def main():
os.makedirs(os.path.join(ROOT, "ExportFiles"), exist_ok=True)
conn = pymysql.connect(**DB, cursorclass=pymysql.cursors.DictCursor)
all_rows = []
try:
with conn.cursor() as cur:
for y, m in PERIODS:
cutoff = month_end(y, m)
label = f"{y}年{m}月截止(累计)"
print(f"统计 {label} ...", flush=True)
all_rows.extend(run_month(cur, cutoff, label))
finally:
conn.close()
fields = [
"截止月份",
"门店ID",
"门店名称",
"总人数",
"新客",
"常到店",
"预警客",
"灰客",
"黑客",
"退款",
]
with open(OUT_CSV, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(all_rows)
print(f"已写入 {OUT_CSV},共 {len(all_rows)} 行\n全公司汇总:")
agg = defaultdict(lambda: defaultdict(int))
for row in all_rows:
k = row["截止月份"]
for col in fields[3:]:
agg[k][col] += row[col]
for label in sorted(agg.keys()):
a = agg[label]
print(
f" {label}: 总{a['总人数']} 新{a['新客']} 常{a['常到店']} "
f"预警{a['预警客']} 灰{a['灰客']} 黑{a['黑客']} 退{a['退款']}"
)
if __name__ == "__main__":
main()