# -*- coding: utf-8 -*- """customer-defectfix 手工复核:I-07 同款含 INSERT 行的 APPEND_ONLY 导入(D-05 终验) 流程(任务书 §三.5): 1. GET /api/customer/import/template 下载模板(模板端点可用性顺带验证) 2. openpyxl 往模板「客户」sheet 追加 2 行新客户(INSERT 行,e2c-defix-* 前缀) —— 若模板结构意外,退回 I-07 同款自造 9 列文件(表头已验证兼容) 3. APPEND_ONLY 上传 → preview insertCount=2 4. confirm → 轮询终态:期望 DONE(status=2);修复前该路径必 FAILED(D-05) 5. DB 断言:customer.is_biz_negotiated=0 且 is_child=0(D-05 修复落库口径) 落盘:.scratch/customer-defectfix/manual-import-result.json 清理:客户行留在库内由残留清理阶段(e2c-* + archive 正门)统一回收。 """ import sys, io, json, time sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') import requests import pymysql from openpyxl import Workbook, load_workbook BASE = 'http://localhost:8080' ADMIN = '739564171091247104' OUT = '.scratch/customer-defectfix/manual-import-result.json' TPL = '.scratch/customer-defectfix/_template-download.xlsx' XLSX = '.scratch/customer-defectfix/_manual-insert.xlsx' PFX = 'e2c-defix' TS = time.strftime('%H%M%S') CTYPE = 'customer_type_01' def db(): return pymysql.connect(host='8.129.84.155', port=3306, user='root', password='Itc@123456', database='crm', charset='utf8mb4', autocommit=True, connect_timeout=10, cursorclass=pymysql.cursors.DictCursor) def dbq(sql, args=None): with db() as conn, conn.cursor() as cur: cur.execute(sql, args) return cur.fetchall() def get_token(uid): r = requests.get(f'{BASE}/api/auth/debug/token', params={'userId': uid}, timeout=30) assert r.status_code == 200, f'debug/token 失败 HTTP {r.status_code}' d = r.json().get('data') tok = d if isinstance(d, str) else (d or {}).get('token') assert tok, f'token 空: {r.text[:120]}' return tok S = requests.Session() S.headers['Authorization'] = f'Bearer {get_token(ADMIN)}' report = {'steps': []} def step(name, ok, detail=''): report['steps'].append({'step': name, 'ok': ok, 'detail': detail}) print((' pass' if ok else ' FAIL'), name, '—', detail) return ok # ---- 1. 下载模板 ---- r = S.get(f'{BASE}/api/customer/import/template', timeout=60) tpl_ok = r.status_code == 200 and r.content[:2] == b'PK' step('template 下载', tpl_ok, f'HTTP {r.status_code} bytes={len(r.content)} magic={r.content[:2]!r}') if not tpl_ok: json.dump(report, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=2) sys.exit(1) open(TPL, 'wb').write(r.content) # ---- 2. 追加 INSERT 行(优先模板路径,退回自造) ---- # 行须带全 NOT NULL 无默认列的值(编号/省/市/行业——同 D-05 同族列群;heavy F-14 r1 同形态), # 缺任一列执行期即行级失败(首轮实证:city_code doesn't have a default value,D-04 截断明细保留) ind = dbq("SELECT i.code c FROM dict_item i JOIN dict_group g ON i.group_id=g.id " "WHERE g.code='industry' AND i.deleted=0 AND i.parent_id IS NOT NULL " "ORDER BY i.sort_no LIMIT 1") ind_code = ind[0]['c'] if ind else '' name_a, name_b = f'{PFX}-插入A-{TS}', f'{PFX}-插入B-{TS}' rows = [ [f'E2C-DEFIX-{TS}A', name_a, CTYPE, None, '440000', '440100', ind_code, '4', 'defix D-05 复核行A'], [f'E2C-DEFIX-{TS}B', name_b, CTYPE, None, '440000', '440100', ind_code, '5', 'defix D-05 复核行B'], ] used_tpl = False try: wb = load_workbook(TPL) ws = wb[wb.sheetnames[0]] head = [c.value for c in ws[1]] if head and '客户名称' in head: for row in rows: ws.append([(row[i] if i < len(row) else None) for i in range(len(head))]) wb.save(XLSX) used_tpl = True except Exception as e: print(' 模板追加失败,退回自造:', e) if not used_tpl: wb = Workbook() ws = wb.active ws.title = '客户' ws.append(['客户编号', '客户名称', '客户类型', '统一社会信用代码', '省份编码', '城市编码', '行业编码', '客户星级(1-5)', '备注']) for row in rows: ws.append(row) wb.save(XLSX) step('构造 INSERT 行文件', True, f'path={XLSX} used_template={used_tpl} names={name_a},{name_b}') # ---- 3. APPEND_ONLY 上传 ---- with open(XLSX, 'rb') as f: r = S.post(f'{BASE}/api/customer/import/upload', files={'file': ('manual-insert.xlsx', f, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}, data={'importMode': 'APPEND_ONLY'}, timeout=60) body = r.json() if r.status_code == 200 else {'_http': r.status_code, '_raw': r.text[:300]} data = body.get('data') or {} task_id = data.get('taskId') or data.get('id') step('upload APPEND_ONLY', r.status_code == 200 and body.get('code') == 0 and task_id is not None, f'code={body.get("code")} task={task_id} preview={json.dumps(data, ensure_ascii=False)[:260]}') report['taskId'] = task_id report['preview'] = data if not task_id: json.dump(report, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=2) sys.exit(1) # ---- 4. confirm + 轮询终态 ---- r = S.post(f'{BASE}/api/customer/import/confirm', params={'taskId': task_id}, timeout=30) cb = r.json() if r.status_code == 200 else {'_http': r.status_code} step('confirm', cb.get('code') == 0, f'code={cb.get("code")} msg={cb.get("message")}') status, result = None, {} for _ in range(30): time.sleep(1) r = S.get(f'{BASE}/api/customer/import/result', params={'taskId': task_id}, timeout=30) result = (r.json().get('data') or {}) if r.status_code == 200 else {} status = str(result.get('status')) if status in ('2', '3'): break report['result'] = result done = status == '2' step('任务终态 DONE(修复前该路径必 FAILED=D-05)', done, f'status={status} insertCount={result.get("insertCount")} failCount={result.get("failCount")} ' f'failReason={str(result.get("failReason"))[:120]}') # ---- 5. DB 断言 ---- rows_db = dbq("SELECT id, customer_no, customer_name, is_biz_negotiated b, is_child c, " "customer_stage st, owner_user_id o FROM customer WHERE customer_name IN (%s,%s)", (name_a, name_b)) ok_db = len(rows_db) == 2 and all(str(x['b']) == '0' and str(x['c']) == '0' for x in rows_db) step('DB is_biz_negotiated=0 且 is_child=0(INSERT 行缺省口径)', ok_db, json.dumps([{k: str(v) for k, v in x.items()} for x in rows_db], ensure_ascii=False)) report['db'] = [{k: str(v) for k, v in x.items()} for x in rows_db] fails = dbq("SELECT COUNT(*) n FROM customer_import_fail WHERE task_id=%s", (int(task_id),)) step('成功任务无失败明细行', int(fails[0]['n']) == 0, f"fail_rows={fails[0]['n']}") report['ALL_OK'] = all(s['ok'] for s in report['steps']) json.dump(report, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=2) print('\n== 手工复核', 'ALL_OK ✅' if report['ALL_OK'] else '存在 FAIL ❌', ',证据落盘', OUT, '==')