#!/usr/bin/env python3 """批量导入所有日出库数据(加固版) 改进: 1. 用 DictReader 代替索引取值,避免列偏移 2. 导入后自动校验货品编号空置率,超阈值自动回滚 3. 记录每条失败原因,不静默吞掉 4. 表头 strip 去空格/BOM,避免隐形字符匹配失败 """ import sys, os, csv, json, subprocess, pymysql, importlib.util, re from datetime import datetime spec = importlib.util.spec_from_file_location("config", "config.py") cfg = importlib.util.module_from_spec(spec) spec.loader.exec_module(cfg) DB_CONFIG = cfg.DB_CONFIG TMP_DIR = "tmp_csv" os.makedirs(TMP_DIR, exist_ok=True) # 钉盘配置 - 限定到日出库文件夹 SPACE_ID = '28814565790' OUTBOUND_FOLDER_ID = '3NwLYZXWynyBjRB4tZjwNxDvVkyEqBQm' # ── 校验阈值 ── EMPTY_CODE_THRESHOLD = 5 # 货品编号空置率超过此百分比(% )则回滚 def safe_float(val): if not val: return 0.0 val = str(val).replace('"', '').replace('=', '').strip() try: return float(val) except: return 0.0 def safe_int(val): if not val: return 0 val = str(val).replace('"', '').replace('=', '').strip() try: return int(float(val)) except: return 0 def clean(val): """清洗单个字段值""" if not val: return '' val = str(val).replace('"', '').replace('=', '').strip() if val == '#N/A': return '' return val def is_invalid_product_code(code): """过滤报表汇总行、占位编码和空编码。""" normalized = clean(code).upper().rstrip(':') return not normalized or normalized in ('NA', 'N/A', '合计') def is_invalid_product_name(name): """过滤货品名称为单横线或双横线的占位数据。""" return clean(name) in ('-', '--') def clean_header(val): """清洗表头字段名 — 去 BOM、引号、等号、首尾空白""" if not val: return '' s = str(val) # 移除 UTF-8 BOM s = s.replace('\ufeff', '') s = s.replace('"', '').replace('=', '').strip() return s def parse_date_from_text(text): """从文件名提取日期""" m = re.search(r'(\d{4})(\d{2})(\d{2})', text) if m: return f'{m.group(1)}-{m.group(2)}-{m.group(3)}' return None SOURCE_MAP = { 'wdt': '旺店通', 'jdbz': '京东泊紫', 'jdgj': '京东国际', 'jdjwt': '京东洁维塔', 'tmgj': '天猫国际', # 钉盘文件实际使用 dymuto(用户口径 dymtuo),表示抖音M店。 'dymuto': '抖音M店', 'dymtuo': '抖音M店', 'jdsmj': '京东适美家', 'jdmrt': '京东美瑞图', 'dyqsimelr': '抖音Q店', } # 预期表头列名(区分全角/半角问题,只做参考) WDT_EXPECTED_COLUMNS = ['货品编号', '货品名称', '品牌', '仓库', '店铺', '一级类目', '二级类目', '三级类目', '货品数量', '货品成本'] def read_csv_rows(filepath): """尝试多种编码读取 CSV,返回 (header_list, data_rows_list) 或 (None, None)""" for enc in ['utf-8-sig', 'gbk', 'gb18030']: try: with open(filepath, 'r', encoding=enc) as f: reader = csv.reader(f) raw_rows = list(reader) if len(raw_rows) > 1: return raw_rows[0], raw_rows[1:] except: continue return None, None def validate_import(cur, date_str, src): """导入后校验:货品编号空置率不能超过阈值""" cur.execute(""" SELECT COUNT(*) AS total, SUM(CASE WHEN 货品编号 IS NULL OR 货品编号 = '' THEN 1 ELSE 0 END) AS empty_code FROM daily_outbound WHERE 日期 = %s AND 数据来源 = %s """, (date_str, src)) row = cur.fetchone() total = row['total'] or 0 empty_code = row['empty_code'] or 0 if total > 0: ratio = empty_code / total * 100 if ratio > EMPTY_CODE_THRESHOLD: raise ValueError( f"货品编号空置率 {ratio:.1f}% ({empty_code}/{total}) " f"超过阈值 {EMPTY_CODE_THRESHOLD}%,已自动回滚" ) return total, empty_code # ── 第一步:获取文件列表(限定到日出库文件夹)── print("📋 正在扫描日出库数据源文件夹...") all_files = {} # name -> nodeId cursor = None while True: args = ['dws', 'drive', 'list', '--space-id', SPACE_ID, '--folder', OUTBOUND_FOLDER_ID, '--limit', '50', '--format', 'json'] if cursor: args.extend(['--cursor', cursor]) result = subprocess.run(args, capture_output=True, text=True, timeout=20) try: data = json.loads(result.stdout).get('result', {}) except: print(f" 解析失败: {result.stdout[:200]}") break for item in data.get('items', []): if item.get('type') != 'FILE': continue name = item['name'].replace('.csv', '') if not re.match(r'\d{8}', name): continue all_files[name] = item['fileId'] cursor = data.get('nextToken') if not cursor: break print(f"找到 {len(all_files)} 个日出库文件") # ── 第二步:按日期分组 ── from collections import defaultdict by_date = defaultdict(list) for name in sorted(all_files.keys()): parts = name.split('-') date_str = parts[0] src = parts[1] by_date[date_str].append(src) for d in sorted(by_date.keys()): srcs = sorted(set(by_date[d])) print(f" {d}: {srcs}") # ── 第三步:查询已有记录 ── conn = pymysql.connect(**DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) with conn.cursor() as cur: cur.execute("SELECT DISTINCT 日期, 数据来源 FROM daily_outbound") existing = set() for r in cur.fetchall(): d = r['日期'].strftime('%Y-%m-%d') if hasattr( r['日期'], 'strftime') else str(r['日期'])[:10] existing.add((d, r['数据来源'])) conn.close() print(f"\n已有记录覆盖 {len(existing)} 个日期+来源组合") # ── 第四步:逐个文件下载并导入 ── total_imported = 0 total_files = 0 skipped = 0 errors = [] for name in sorted(all_files.keys()): node_id = all_files[name] parts = name.split('-') date_str = parts[0] src = parts[1] src_label = SOURCE_MAP.get(src, src) data_date = parse_date_from_text(name) # 同时检测来源代码和中文名称,防止因名称不一致重复导入 src_label = SOURCE_MAP.get(src, src) skip_reason = None if data_date: if (data_date, src) in existing: skip_reason = f"来源代码({src})" elif src != src_label and (data_date, src_label) in existing: skip_reason = f"来源名称({src_label})" if skip_reason: print(f" ⏭️ 跳过已存在: {name} ({skip_reason})") skipped += 1 continue total_files += 1 print(f"\n[{total_files}] {name} ({src_label}, 日期:{data_date})") # ─── 下载文件 ─── local_path = os.path.join(TMP_DIR, f"{name}.csv") result = subprocess.run( ['dws', 'drive', 'download', '--node', node_id, '--space-id', SPACE_ID, '--output', local_path, '--format', 'json'], capture_output=True, text=True, timeout=60 ) if result.returncode != 0 or not os.path.exists(local_path) or os.path.getsize(local_path) < 10: msg = f"下载失败: {result.stderr[:200]}" print(f" ❌ {msg}") errors.append((name, msg)) continue # ─── 读取 CSV ─── header, data_rows = read_csv_rows(local_path) if header is None or data_rows is None: msg = f"读取CSV失败(编码/格式异常)" print(f" ❌ {msg}") errors.append((name, msg)) continue print(f" 读取到 {len(data_rows)} 条记录, 列数: {len(header)}") print(f" 表头: {header}") # ── 清洗表头 ── clean_headers = [clean_header(h) for h in header] print(f" 清洗后: {clean_headers}") # ─── 根据来源导入 ─── conn = pymysql.connect(**DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) inserted = 0 fail_count = 0 fail_samples = [] try: with conn.cursor() as cur: if src == 'wdt': # ── wdt 完整格式 — 使用 DictReader 安全按列名取值 ── # 重新以 DictReader 读取,确保列名与数据一一对应 for enc in ['utf-8-sig', 'gbk', 'gb18030']: try: f = open(local_path, 'r', encoding=enc) dict_reader = csv.DictReader(f) # 清洗 DictReader 的 fieldnames dict_reader.fieldnames = [clean_header(h) for h in dict_reader.fieldnames] break except: if 'f' in dir(): f.close() continue else: raise RuntimeError("无法以任何编码读取CSV") with f: for row in dict_reader: try: product_code = clean(row.get('货品编号', '')) # 关键校验:货品编号不能为空 if is_invalid_product_code(product_code): fail_count += 1 if fail_count <= 3: fail_samples.append( f"行号? 无效货品编号={product_code}, 名称={row.get('货品名称','')[:20]}") continue product_name = clean(row.get('货品名称', '')) if is_invalid_product_name(product_name): fail_count += 1 continue brand = clean(row.get('品牌', '')) warehouse = clean(row.get('仓库', '')) shop = clean(row.get('店铺', '')) cat1 = clean(row.get('一级类目', '')) cat2 = clean(row.get('二级类目', '')) cat3 = clean(row.get('三级类目', '')) qty = safe_int(row.get('货品数量', 0)) cost = safe_float(row.get('货品成本', 0)) cur.execute(""" INSERT INTO daily_outbound (货品编号, 货品名称, 品牌, 仓库, 店铺, 一级类目, 二级类目, 三级类目, 货品数量, 货品成本, 日期, 数据来源, 数据类型) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, (product_code, product_name, brand, warehouse, shop, cat1, cat2, cat3, qty, cost, data_date, src, '出库数据')) inserted += 1 except Exception as e: fail_count += 1 if fail_count <= 3: fail_samples.append(f"写入异常: {e}") else: # ── 简版格式(jdbz/jdgj/jdjwt/tmgj/dymuto/jdsmj)── for raw_row in data_rows: if not raw_row or len(raw_row) < 2: fail_count += 1 continue product_code = clean(raw_row[0]) qty = safe_int(raw_row[1]) if is_invalid_product_code(product_code): fail_count += 1 continue cur.execute(""" SELECT product_name, brand, category1, category2, category3, cost_price FROM products WHERE product_code=%s LIMIT 1 """, (product_code,)) prod = cur.fetchone() if prod: pname = prod['product_name'] or '' if is_invalid_product_name(pname): fail_count += 1 continue brand = prod['brand'] or '' cat1 = prod['category1'] or '' cat2 = prod['category2'] or '' cat3 = prod['category3'] or '' cost = float(prod['cost_price']) if prod['cost_price'] else 0 else: pname = '' brand = '' cat1 = '' cat2 = '' cat3 = '' cost = 0 try: cur.execute(""" INSERT INTO daily_outbound (货品编号, 货品名称, 品牌, 仓库, 店铺, 一级类目, 二级类目, 三级类目, 货品数量, 货品成本, 日期, 数据来源, 数据类型) VALUES (%s,%s,%s,'','',%s,%s,%s,%s,%s,%s,%s,%s) """, (product_code, pname, brand, cat1, cat2, cat3, qty, cost, data_date, src, '出库数据')) inserted += 1 except Exception as e: fail_count += 1 if fail_count <= 3: fail_samples.append(f"写入异常: {e}") # ── 导入后校验 ── validate_import(cur, data_date, src) # 全部通过,提交 conn.commit() except ValueError as ve: # 校验失败 → 回滚 conn.rollback() conn.close() msg = f"❌ 校验失败: {ve}" print(f" {msg}") errors.append((name, str(ve))) continue except Exception as e: conn.rollback() conn.close() msg = f"❌ 导入异常: {e}" print(f" {msg}") errors.append((name, str(e))) continue conn.close() # ── 打印导入小结 ── print(f" ✅ 导入 {inserted} 条", end='') if fail_count > 0: print(f", 跳过 {fail_count} 条异常记录") for s in fail_samples[:3]: print(f" 示例: {s}") else: print() total_imported += inserted # ── 最终汇总 ── print(f"\n{'='*50}") print(f"批量导入完成!") print(f"新导入: {total_files} 个文件, {total_imported} 条记录") print(f"已跳过: {skipped} 个已存在的文件") if errors: print(f"\n⚠️ 以下文件导入失败:") for name, reason in errors: print(f" - {name}: {reason}") else: # 导入脚本独立于 Gunicorn 进程运行,必须清除共享缓存文件, # 让各 worker 放弃导入前的库存/计划预测结果。 try: from services.cache import refresh_all_caches refreshed = refresh_all_caches() print(f"✅ 导入后计算缓存已清除({len(refreshed)} 个模块)") except Exception as e: print(f"⚠️ 导入后计算缓存清除失败: {e}") print(f"{'='*50}")