ingest.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. # -*- coding: utf-8 -*-
  2. """
  3. 数据导入(CSV / Excel xlsx)
  4. - 编码:utf-8-sig → gbk → utf-8(容错)
  5. - 表头:走别名映射,抖音来客导出的不同列名都能认
  6. - 粒度:有日期列/传入日期 → 按日入库(daily);否则整表当周期汇总(period)
  7. - 幂等:同 门店+周期+日期+粒度 覆盖写入,重复导入不会翻倍
  8. """
  9. import csv
  10. import io
  11. import os
  12. import re
  13. from datetime import datetime
  14. import db
  15. import xlsx
  16. # canonical -> 可能的表头写法(已归一化:去空格/全角括号转半角)
  17. ALIASES = {
  18. "store_id": ["门店id", "门店编号", "门店编码", "poiid", "shopid", "storeid", "id"],
  19. "name": ["门店名称", "门店名", "店铺名称", "shopname", "storename", "name"],
  20. "city": ["所在城市", "城市", "city"],
  21. "date": ["日期", "统计日期", "数据日期", "时间", "date"],
  22. "visit": ["门店页访问次数", "访问次数", "门店访问", "访问量", "visit"],
  23. "deal": ["门店页成交券数", "成交券数", "成交单量"],
  24. "deal_refund": ["门店页成交后退款券数", "成交后退款券数"],
  25. "intend_deal": ["门店意向成交券数", "意向成交券数", "下单券数", "成交券数(意向)"],
  26. "used_amount": ["门店核销金额", "核销金额", "核销gmv", "核销交易额"],
  27. "used_coupons": ["门店核销券数", "核销券数", "核销量", "核销单量"],
  28. "intend_refund": ["门店意向退款券数", "意向退款券数"],
  29. "videos": ["新发布门店关联视频数", "新发布视频数", "关联视频数", "发布视频数"],
  30. "video_views": ["门店视频播放次数", "视频播放次数", "播放量"],
  31. "score": ["门店评分", "评分", "score"],
  32. "reviews_new": ["新增评价数", "新增评价"],
  33. "good_new": ["新增好评数", "新增好评"],
  34. "bad_new": ["新增中差评数", "新增差评数", "新增中差评"],
  35. "reviews_total": ["累计评价数", "总评价数"],
  36. "good_total": ["累计好评数", "总好评数"],
  37. "bad_total": ["累计中差评数", "累计差评数"],
  38. "reply_rate": ["评价回复率", "回复率"],
  39. "complaint_rate": ["经营风险投诉率", "投诉率"],
  40. "return_rate": ["经营风险商责退单率", "商责退单率", "退单率"],
  41. "lit": ["门店点亮数", "点亮数"],
  42. "lit_posts": ["点亮后的投稿数", "点亮投稿数"],
  43. "favs": ["门店收藏数", "收藏数"],
  44. "rank_note": ["上榜信息及排名", "榜单排名", "上榜信息"],
  45. }
  46. RATE_FIELDS = {"reply_rate", "complaint_rate", "return_rate"}
  47. INT_FIELDS = {"visit", "deal", "deal_refund", "intend_deal", "used_coupons",
  48. "intend_refund", "videos", "video_views", "reviews_new", "good_new",
  49. "bad_new", "reviews_total", "good_total", "bad_total", "lit", "lit_posts", "favs"}
  50. FLOAT_FIELDS = {"used_amount", "score"}
  51. TEXT_FIELDS = {"rank_note"}
  52. def norm_header(h):
  53. h = (h or "").strip().replace("\ufeff", "")
  54. h = h.replace("(", "(").replace(")", ")")
  55. h = re.sub(r"\s+", "", h)
  56. return h.lower()
  57. def build_map(header):
  58. """返回 canonical -> 列下标"""
  59. norm = [norm_header(h) for h in header]
  60. m = {}
  61. for canon, names in ALIASES.items():
  62. for i, h in enumerate(norm):
  63. if h in [n.lower() for n in names]:
  64. m[canon] = i
  65. break
  66. return m
  67. def decode(raw):
  68. for enc in ("utf-8-sig", "gbk", "utf-8"):
  69. try:
  70. return raw.decode(enc), enc
  71. except UnicodeDecodeError:
  72. continue
  73. return raw.decode("utf-8", errors="replace"), "utf-8(replace)"
  74. def to_num(v, as_rate=False):
  75. if v is None:
  76. return None
  77. s = str(v).strip().replace(",", "").replace(" ", "")
  78. if s in ("", "-", "--", "None", "null", "nan"):
  79. return None
  80. has_pct = s.endswith("%")
  81. if has_pct:
  82. s = s[:-1]
  83. try:
  84. x = float(s)
  85. except ValueError:
  86. return None
  87. if as_rate:
  88. # 带 % 或数值大于 1,都视为百分数
  89. if has_pct or x > 1:
  90. x = x / 100.0
  91. return x
  92. def _valid_ymd(y, mo, d):
  93. """合法性校验:月份 1-12、日期 1-31,不合法返回 None(避免出现 2026-01-00 这种)"""
  94. try:
  95. y, mo, d = int(y), int(mo), int(d)
  96. except (TypeError, ValueError):
  97. return None
  98. if not (1 <= mo <= 12) or not (1 <= d <= 31):
  99. return None
  100. return y, mo, d
  101. # 生意经「按统计周期」导出的统计日期是区间,如 2026-10-03-2026-11-01 / 2026-10-03~2026-11-01
  102. RANGE_RE = re.compile(
  103. r"^\s*(20\d{2})[-/.]?(\d{1,2})[-/.](\d{1,2})\s*(?:-|~|~|至|到)\s*"
  104. r"(20\d{2})[-/.]?(\d{1,2})[-/.](\d{1,2})\s*$")
  105. def _norm_date(y, m, d):
  106. try:
  107. return "%04d-%02d-%02d" % (int(y), int(m), int(d))
  108. except (TypeError, ValueError):
  109. return ""
  110. def parse_range(v):
  111. """把统计日期区间解析成 (start, end);不是区间返回 None"""
  112. m = RANGE_RE.match(str(v or "").replace(" ", ""))
  113. if not m:
  114. return None
  115. a = _norm_date(m.group(1), m.group(2), m.group(3))
  116. b = _norm_date(m.group(4), m.group(5), m.group(6))
  117. if not a or not b or a > b:
  118. return None
  119. return a, b
  120. def range_in_filename(filename):
  121. """文件名里带日期区间(如 …_2026-10-03_2026-11-01.xlsx)→ 返回 (start, end)"""
  122. m = re.search(r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})\s*[_~-]+\s*"
  123. r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})", str(filename or ""))
  124. if not m:
  125. return None
  126. a = _norm_date(m.group(1), m.group(2), m.group(3))
  127. b = _norm_date(m.group(4), m.group(5), m.group(6))
  128. if a and b and a <= b:
  129. return a, b
  130. return None
  131. def guess_period_date(filename, period, date):
  132. """
  133. 没给日期/月份时,从文件名猜。顺序:先认「年月日」,再认「年月」,最后认「月」。
  134. 日粒度:2026-10-30 / 2026/10/30 / 2026.10.30 / 2026年10月30日 / 20261030 / 10.28
  135. 月粒度:2026-10 / 2026年10月 / 2026.10 / 202610 / 10月
  136. 关键点:2026-10 这类「年月」不能被误读成「2026 年 1 月 0 日」——
  137. 所以完整日期必须分隔符齐全、且年月日都合法,才按日粒度处理。
  138. """
  139. if date and not period:
  140. period = date[:7]
  141. if date or period:
  142. return period, date
  143. fn = filename or ""
  144. # 1) 带分隔符的完整日期:2026-10-30、2026/10/30、2026.10.30、2026年10月30日
  145. m = re.search(r"(20\d{2})\s*[-_/.年]\s*(\d{1,2})\s*[-_/.月]\s*(\d{1,2})(?!\d)", fn)
  146. if m:
  147. ymd = _valid_ymd(*m.groups())
  148. if ymd:
  149. return "%04d-%02d" % ymd[:2], "%04d-%02d-%02d" % ymd
  150. # 2) 无分隔符的完整日期:20261030
  151. m = re.search(r"(?<!\d)(20\d{2})(\d{2})(\d{2})(?!\d)", fn)
  152. if m:
  153. ymd = _valid_ymd(*m.groups())
  154. if ymd:
  155. return "%04d-%02d" % ymd[:2], "%04d-%02d-%02d" % ymd
  156. # 3) 带分隔符的年月:2026-10、2026年10月、2026.10
  157. m = re.search(r"(20\d{2})\s*[-_/.年]\s*(\d{1,2})(?!\d)", fn)
  158. if m:
  159. y, mo = int(m.group(1)), int(m.group(2))
  160. if 1 <= mo <= 12:
  161. return "%04d-%02d" % (y, mo), ""
  162. # 4) 无分隔符的年月:202610
  163. m = re.search(r"(?<!\d)(20\d{2})(\d{2})(?!\d)", fn)
  164. if m:
  165. y, mo = int(m.group(1)), int(m.group(2))
  166. if 1 <= mo <= 12:
  167. return "%04d-%02d" % (y, mo), ""
  168. # 5) 只有月份:10月 / 10.28(用当前年份)
  169. y = datetime.now().year
  170. m = re.search(r"(\d{1,2})\s*月", fn)
  171. if m and 1 <= int(m.group(1)) <= 12:
  172. return "%04d-%02d" % (y, int(m.group(1))), ""
  173. m = re.search(r"(?<!\d)(\d{1,2})\.(\d{1,2})(?!\d)", fn)
  174. if m:
  175. ymd = _valid_ymd(y, m.group(1), m.group(2))
  176. if ymd:
  177. return "%04d-%02d" % ymd[:2], "%04d-%02d-%02d" % ymd
  178. return datetime.now().strftime("%Y-%m"), ""
  179. def import_rows(rows, filename, period=None, date=None, source="csv", window=None, file_path=None):
  180. """rows: [[str,...], ...],第一行为表头。CSV 与 Excel 共用此入库逻辑。"""
  181. rows = [r for r in rows]
  182. # 去掉全空行/表头前的杂讯行:找到含「门店名称或门店ID」表头的行作为表头
  183. start = 0
  184. for i, r in enumerate(rows[:20]):
  185. normed = [norm_header(c) for c in r]
  186. if any(h in ("门店id", "门店编号", "门店编码", "poiid") for h in normed) \
  187. and any("门店名称" in h or h == "店铺名称" for h in normed):
  188. start = i
  189. break
  190. rows = rows[start:]
  191. if not rows:
  192. return {"ok": False, "error": "文件为空"}
  193. reader = rows
  194. header = [str(c) if c is not None else "" for c in reader[0]]
  195. cmap = build_map(header)
  196. missing = [k for k in ("store_id", "name") if k not in cmap]
  197. if missing:
  198. return {"ok": False, "error": "表头缺少必要列:门店ID / 门店名称",
  199. "header": header}
  200. # window=(start, end) 时强制按「近 N 天快照」入库(生意经按统计周期导出没有日期列)
  201. win_force = None
  202. if not window:
  203. window = range_in_filename(filename)
  204. if window:
  205. a, b = (window[0] or "").strip(), (window[1] or "").strip()
  206. if a and b and a <= b:
  207. win_force = (a, b)
  208. if not period:
  209. period = b
  210. if not date:
  211. date = b
  212. period, date = guess_period_date(filename, period, date)
  213. conn = db.connect()
  214. today = datetime.now().date().isoformat()
  215. rows_read = rows_written = new_stores = duplicates = 0
  216. seen_keys = set()
  217. errors = []
  218. dates = set()
  219. grams = set() # 实际入库用到的粒度(daily / period / snapshot)
  220. win_seen = set() # 快照的统计窗口
  221. period_cnt = {} # 实际入库的周期分布,用于回报「导到哪个月了」
  222. period_val = period
  223. with conn:
  224. batch_id = db.record_batch(conn, {"filename": filename, "rows_read": 0, "rows_written": 0,
  225. "file_path": file_path,
  226. "new_stores": 0, "period": period, "note": "importing"})
  227. for line_no, r in enumerate(reader[1:], start=2):
  228. r = [("" if c is None else str(c)) for c in r]
  229. if not r or not any(c.strip() for c in r):
  230. continue
  231. rows_read += 1
  232. def cell(key):
  233. i = cmap.get(key)
  234. return r[i] if i is not None and i < len(r) else None
  235. sid = (cell("store_id") or "").strip()
  236. name = (cell("name") or "").strip()
  237. if not sid or not name:
  238. if len(errors) < 5:
  239. errors.append("第%d行缺少门店ID或名称" % line_no)
  240. continue
  241. row_date = (cell("date") or "").strip()
  242. win_start = win_end = ""
  243. if row_date:
  244. rng = parse_range(row_date)
  245. if rng: # 区间 → 近 N 天快照
  246. win_start, win_end = rng
  247. else:
  248. row_date = row_date.replace("/", "-").replace(".", "-")
  249. m = re.match(r"(20\d{2})-(\d{1,2})-(\d{1,2})", row_date)
  250. if m:
  251. row_date = "%s-%02d-%02d" % (m.group(1), int(m.group(2)), int(m.group(3)))
  252. else:
  253. row_date = date or ""
  254. if win_force:
  255. win_start, win_end = win_force
  256. if win_start and win_end and win_start != win_end:
  257. # 滚动窗口:period 记「截至日」,每店一行,window_start/end 保留区间
  258. p, g, row_date = win_end, "snapshot", win_end
  259. elif win_start and win_end:
  260. p, g, row_date = win_start[:7], "daily", win_start # 首尾同一天 = 单日
  261. win_start = win_end = ""
  262. else:
  263. p = row_date[:7] if row_date else period_val
  264. g = "daily" if row_date else "period"
  265. key = (sid, p, row_date, g)
  266. if key in seen_keys:
  267. duplicates += 1
  268. if len(errors) < 5:
  269. errors.append("第%d行与其他行门店/日期重复,已跳过" % line_no)
  270. continue
  271. seen_keys.add(key)
  272. if row_date:
  273. dates.add(row_date)
  274. rec = {"store_id": sid, "period": p, "date": row_date, "granularity": g,
  275. "window_start": win_start, "window_end": win_end}
  276. for key in INT_FIELDS:
  277. v = to_num(cell(key))
  278. rec[key] = int(v) if v is not None else 0
  279. for key in FLOAT_FIELDS:
  280. rec[key] = to_num(cell(key))
  281. for key in RATE_FIELDS:
  282. rec[key] = to_num(cell(key), as_rate=True)
  283. for key in TEXT_FIELDS:
  284. rec[key] = (cell(key) or "").strip() or None
  285. city = (cell("city") or "").strip()
  286. if db.upsert_store(conn, sid, name, city, today):
  287. new_stores += 1
  288. db.insert_metric(conn, rec, batch_id)
  289. rows_written += 1
  290. grams.add(g)
  291. period_cnt[p] = period_cnt.get(p, 0) + 1
  292. if g == "snapshot" and win_start and win_end:
  293. win_seen.add((win_start, win_end))
  294. conn.execute("""UPDATE batches SET rows_read=?, rows_written=?, new_stores=?,
  295. period=?, date_from=?, date_to=?, note=? WHERE id=?""",
  296. (rows_read, rows_written, new_stores,
  297. (max(period_cnt.items(), key=lambda kv: kv[1])[0] if period_cnt
  298. else (sorted(dates)[0][:7] if dates else period_val)),
  299. min(dates) if dates else "", max(dates) if dates else "",
  300. "ok" if rows_written else "无有效数据", batch_id))
  301. conn.close()
  302. # 回包口径以「实际写进库的行」为准:跨月文件按行数最多的那个月回报
  303. out_period = max(period_cnt.items(), key=lambda kv: kv[1])[0] if period_cnt else period_val
  304. granularity = ("snapshot" if "snapshot" in grams
  305. else "daily" if "daily" in grams else "period")
  306. return {"ok": rows_written > 0, "source": source, "filename": filename,
  307. "rows_read": rows_read, "rows_written": rows_written,
  308. "new_stores": new_stores, "granularity": granularity,
  309. "period": out_period, "date": date,
  310. "periods": sorted(period_cnt.items(), key=lambda kv: -kv[1]),
  311. "windows": [{"start": a, "end": b} for a, b in sorted(win_seen)],
  312. "date_range": [min(dates), max(dates)] if dates else [],
  313. "skipped": rows_read - rows_written, "duplicates": duplicates, "errors": errors,
  314. "columns_matched": len(cmap)}
  315. def import_csv(raw, filename, period=None, date=None, window=None, file_path=None):
  316. text, enc = decode(raw)
  317. sample = text[:4096]
  318. try:
  319. dialect = csv.Sniffer().sniff(sample, delimiters=",\t;")
  320. delim = dialect.delimiter
  321. except csv.Error:
  322. delim = ","
  323. rows = list(csv.reader(io.StringIO(text), delimiter=delim))
  324. r = import_rows(rows, filename, period=period, date=date, source="csv", window=window, file_path=file_path)
  325. r["encoding"] = enc
  326. return r
  327. def import_xlsx(raw, filename, period=None, date=None, window=None, file_path=None):
  328. rows, sheets = xlsx.read_bytes(raw)
  329. r = import_rows(rows, filename, period=period, date=date, source="xlsx", window=window, file_path=file_path)
  330. r["sheets"] = sheets
  331. return r
  332. def import_bytes(raw, filename, period=None, date=None, window=None, file_path=None):
  333. """按扩展名分发:.xlsx 走 Excel 解析,其余按 CSV。window=(start,end) 强制按快照入库。"""
  334. if filename.lower().endswith(".xlsx"):
  335. return import_xlsx(raw, filename, period=period, date=date, window=window, file_path=file_path)
  336. return import_csv(raw, filename, period=period, date=date, window=window, file_path=file_path)
  337. def import_file(path, period=None, date=None, file_path=None):
  338. with open(path, "rb") as fp:
  339. raw = fp.read()
  340. return import_bytes(raw, os.path.basename(path), period=period, date=date, file_path=file_path)