#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从 ERP 销售订单导出（销售管理视图）的备注字段提取「客户特殊要求」
================================================================
口径（用户 2026-09-20 明确）：**只统计 ERP 导出订单备注里的特殊要求，不统计飞书活动里提出的**。
数据源：`销售订单导出-销售管理*.xlsx`（含 `备注(内部)#` / `备注(客户)#` 两列，且带分组表头）
       —— ERP 的「销售订单_YYYYMMDD」格式**没有备注列**，不能用作数据源。

用法：
  python3 extract_erp_remarks.py --filter                    # 只做规则预筛，看候选量与抽样
  python3 extract_erp_remarks.py --run --shards 6 --shard 0 --batch 20
  python3 extract_erp_remarks.py --merge 6                   # 合并分片 → 数据/_erp_sr_extracted.json

设计要点：
  · 候选去重键 = 客户 + 备注正文（同一要求被多次下单重复写，合并成一条并记录出现次数）
  · 归属人（owner）= 导出文件分组表头里的「销售员」；部门同理 —— 用于后续判权
  · 单据编号 ZHBR260901062 → 日期 2026-09-01（导出没有日期列，编号里带）
  · 依据（evidence）必须是备注原文原句，供人工/复核页追溯
"""
import os
import re
import sys
import json
import time

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(ROOT, '数据')
CAND = os.path.join(OUT_DIR, '_erp_sr_candidates.json')
INPUT = os.path.join(OUT_DIR, '_erp_sr_input.json')
MERGED = os.path.join(OUT_DIR, '_erp_sr_extracted.json')
DEFAULT_SOURCE = os.path.join(OUT_DIR, '销售订单导出-销售管理.xlsx')

CATS = ['搭配规则', '配方/体系', '包装/标签', '交付/物流', '质检/文件', '价格/账期', '其他']

# 像「客户要求」的备注（宁宽勿窄，误判交给模型第二道）
KEEP = re.compile(
    r'要求|需|必须|指定|不能|不要|禁止|按客户|客户要|定制|贴|分装|包装|纸箱|标签|说明书|COA|MSDS|质检|报告|盖章|合同|原件'
    r'|温度|冷藏|冰袋|干冰|运输|快递|保存|效期|有效期|批次|批号|浓度|规格|体系|ml|T/包|logo|LOGO|丝印|喷码|无菌|无酶'
    r'|RNA酶|DNase|防污染|溯|客诉|投诉|返|抵|收货人|收件|送|发货|第三方|计量|校准|同批|同批号')
# 纯内部物流/流程，与「客户特殊要求」无关
DROP = re.compile(r'^预测单|^加急$|^总部发货$|^订单号：|^\*+$|^测试$|^无$|^-$|^待定$|^转销|虚拟订单')
# 非真实客户：导出里的掩码客户名、以及宝锐/宝泰仪自身主体（内部转移单）
SELF = re.compile(r'^\*+$|^宝锐|^珠海宝锐|宝泰仪|^nan$|^未知')


def _s(v):
    """Excel 单元格 → 干净字符串（含 NaN 归一，见 skill 记录）"""
    if v is None:
        return ''
    t = str(v).strip()
    if t.lower() in ('nan', 'none', 'null', 'nat'):
        return ''
    return t


def doc_date(doc):
    """单据编号 ZHBR260901062 → 2026-09-01"""
    m = re.search(r'ZHBR(\d{2})(\d{2})(\d{2})', _s(doc))
    if not m:
        return ''
    yy, mm, dd = m.groups()
    return '20%s-%s-%s' % (yy, mm, dd)


def load_source(path):
    """读一个销售管理导出 → 候选记录（跟踪分组表头拿 部门/销售员）"""
    import pandas as pd
    if not os.path.exists(path):
        return []
    try:
        d = pd.read_excel(path)
    except Exception as e:
        sys.stderr.write('  读取失败 %s: %s\n' % (os.path.basename(path), e))
        return []
    col = lambda key: next((c for c in d.columns if key in str(c)), None)
    c_cust, c_doc, c_sku = col('销售订单关联客户'), col('单据编号'), col('货号#')
    c_i, c_c = col('备注(内部)'), col('备注(客户)')
    c_name = col('物料名称')
    if not (c_cust and (c_i or c_c)):
        return []
    # 分组表头里既有「部门」「销售员」也有「客户」（客户也占一行表头）——
    # 先把全部客户名收集起来，用来区分「客户表头」与「销售员表头」，否则销售员会被填成客户名。
    # ⚠️ 客户集合必须排除表头行本身（表头文本是「X」共N个，不排除会导致判断永远失败）
    all_cust = {_s(v) for v in d[c_cust].tolist() if v is not None and '「' not in _s(v)}
    out = []
    dept = owner = ''
    for _, r in d.iterrows():
        cust = _s(r.get(c_cust))
        if '「' in cust:                       # 分组表头行：「部门」/「销售员」/「客户」共N个
            name = cust.strip('「」').split('」')[0]
            if not name:
                continue
            if name in all_cust:               # 客户表头 → 不改归属人
                continue
            if name.endswith(('部', '组', '中心', '事业部', '销售公司')):
                dept = name
                owner = ''
            else:
                owner = name
            continue
        if not cust or SELF.search(cust):
            continue
        parts = [_s(r.get(c_i)), _s(r.get(c_c))]
        txt = '\n'.join(p for p in parts if p)
        if len(txt) < 4 or DROP.match(txt) or not KEEP.search(txt):
            continue
        out.append({
            'customer': cust,
            'owner': owner,
            'dept': dept,
            'doc': _s(r.get(c_doc)),
            'date': doc_date(r.get(c_doc)),
            'sku': _s(r.get(c_sku)),
            'product': _s(r.get(c_name))[:40],
            'text': txt,
            'src': os.path.basename(path),
        })
    return out


def load_all():
    """全部 ERP 销售管理导出 → 候选（客户+备注去重，记录出现次数与全部单据号）"""
    import glob
    paths = [DEFAULT_SOURCE]
    for pat in ('数据/**/销售订单导出-销售管理*.xlsx', '数据/**/销售订单导出*.xlsx'):
        paths += glob.glob(os.path.join(ROOT, pat), recursive=True)
    seen_p, uniq_p = set(), []
    for p in paths:
        rp = os.path.realpath(p)
        if rp in seen_p:
            continue
        seen_p.add(rp)
        uniq_p.append(p)
    recs = []
    for p in uniq_p:
        recs += load_source(p)
    # 去重：客户 + 备注正文
    groups = {}
    for r in recs:
        k = (r['customer'], re.sub(r'\s+', '', r['text']))
        g = groups.get(k)
        if not g:
            groups[k] = dict(r, hits=1, docs=[r['doc']])
        else:
            g['hits'] += 1
            if r['doc'] and r['doc'] not in g['docs']:
                g['docs'].append(r['doc'])
            if not g.get('owner') and r.get('owner'):
                g['owner'] = r['owner']
            if not g.get('date') and r.get('date'):
                g['date'] = r['date']
    out = []
    for i, g in enumerate(groups.values()):
        g['id'] = '%s#%d' % (g['docs'][0] if g['docs'] else 'NO_DOC', i + 1)
        g['docs'] = g['docs'][:8]
        out.append(g)
    out.sort(key=lambda x: (x['customer'], x['date'] or ''))
    return out


SYS = (
    '你是宝锐生物（诊断原料/生命科学试剂供应商）的销售运营助理。'
    '现在给你的是 ERP 销售订单里的**订单备注**（含内部备注与客户备注两类），'
    '请从中提取**客户提出的特殊要求**（对产品、包装、标签、交付、质检文件、价格账期的持续性或一次性明确要求）。\n'
    '要求：\n'
    '1. 只提取备注里**明确写着**的内容，绝不推测、不补充常识、不编造。\n'
    '2. 排除：内部物流安排（预测单号、加急、走哪家快递除非是客户指定）、纯订单号、'
    '付款/开票流程本身、销售内部备忘、与本客户无关的内容；'
    '**「本单/该订单」这类一次性处理（本次不发货、本单直接带走、本单已沟通）也不要提取**，'
    '只保留具有持续性或客户明确提出的要求。\n'
    '3. 「依据」必须是备注里的**原句原文**（可截取但不改写），这是可追溯的关键。\n'
    '4. 类别只能取：' + '、'.join(CATS) + '；不确定归「其他」。\n'
    '5. 同一备注里有多条互相独立的要求时，拆成多条。\n'
    '6. 置信度：备注明确写了该要求=高；需从上下文推断该要求=中；不确定=低。\n'
    '只输出 JSON 数组，不要任何解释或 markdown 代码块。格式：\n'
    '[{"客户":"","类别":"","标题":"","内容":"客户的要求（一句话，第三人称陈述，保留关键数值/规格）",'
    '"依据":"备注原文原句","涉及货号":[],"置信度":"高"}]'
)


def _glm(system_prompt, user_msg):
    """直连智谱。默认用 glm-4-flash：ERP 备注这种「原文抽取」任务上，
    glm-5.3-flash（思考模型）的 reasoning 会随输入线性膨胀——12 条备注要 4.7 分钟，
    glm-4-flash 同批 138 秒且同样给出原文依据，吞吐提升一倍（2026-09-20 实测）。
    可用环境变量 ERPSR_MODEL 覆盖。"""
    import urllib.request
    import ai_chat
    cfg = ai_chat._load_glm_cfg()
    if not cfg.get('api_key'):
        return None, 'GLM 凭证未配置'
    body = {'model': os.environ.get('ERPSR_MODEL') or 'glm-4-flash',
            'messages': [{'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': user_msg}],
            'max_tokens': 16000, 'temperature': 0.3}
    req = urllib.request.Request(
        '%s/chat/completions' % cfg['base_url'], data=json.dumps(body).encode('utf-8'),
        headers={'Content-Type': 'application/json', 'Authorization': 'Bearer %s' % cfg['api_key']}, method='POST')
    with urllib.request.urlopen(req, timeout=180) as r:
        d = json.loads(r.read())
    m = d.get('choices', [{}])[0].get('message', {})
    return (m.get('content') or m.get('reasoning_content') or '').strip(), None


def do_run(limit=0, batch=20, shard=0, shards=1, src_file=None):
    import ai_chat
    cands = json.load(open(src_file or INPUT, encoding='utf-8'))['candidates']
    sys.stderr.write('  候选 %d ｜ 分片 %d/%d ｜ 每批 %d 条\n' % (len(cands), shard + 1, shards, batch))
    out_path = os.path.join(OUT_DIR, '_erp_sr_extracted_s%d.json' % shard)
    state = {'by_doc': {}, 'items': []}
    if os.path.exists(out_path):
        try:
            state = json.load(open(out_path, encoding='utf-8'))
        except Exception:
            pass
    todo = [c for i, c in enumerate(cands) if i % shards == shard and c['id'] not in state['by_doc']]
    if limit:
        todo = todo[:limit]
    sys.stderr.write('  本分片待处理 %d 条（跳过已完成 %d）\n' % (len(todo), len(cands) - len(todo)))
    ok = bad = 0
    queue = [(todo[i:i + batch]) for i in range(0, len(todo), batch)]
    done_batches = 0
    total_batches = len(queue)
    while queue:
        grp = queue.pop(0)
        done_batches += 1
        payload = [{'客户': c['customer'], '单据编号': c['doc'], '货号': c['sku'],
                    '备注': c['text'][:350]} for c in grp]
        content, e = None, None
        err = ''
        for _try in range(3):                  # 网络/限流异常退避重试，绝不带走整个分片
            try:
                content, e = _glm(
                    SYS, '请从以下 %d 条 ERP 订单备注中提取客户特殊要求（只输出 JSON 数组）：\n%s'
                    % (len(payload), json.dumps(payload, ensure_ascii=False)))
                err = e or ''
            except Exception as ex:
                content, err = None, str(ex)
            if content:
                err = ''
                break
            time.sleep(30 if '429' in err else 10)
        items = []
        parsed_ok = False
        if content:
            m = re.search(r'\[.*\]', content, re.S)
            if m:
                try:
                    items = json.loads(m.group(0))
                    parsed_ok = True
                except Exception:
                    try:
                        items = json.loads(re.sub(r'\s+', ' ', m.group(0)))
                        parsed_ok = True
                    except Exception:
                        pass
        if not parsed_ok and not err and len(grp) >= 8:
            # 只有「模型正常返回但 JSON 没解析出来」才折半；限流/超时折半只会把请求数翻倍、雪崩
            half = len(grp) // 2
            queue = [grp[:half], grp[half:]] + queue
            total_batches += 2
            done_batches -= 1
            sys.stderr.write('  批 %d（%d 条）JSON 解析失败 → 折半重试\n' % (done_batches, len(grp)))
            continue
        if not parsed_ok:
            # 失败批次不标记完成 → 重跑时自动补上（宁可留缺口，不可丢数据）
            bad += 1
            sys.stderr.write('  批 %d/%d ✗ 失败（%s）\n' % (done_batches, total_batches, (err or '解析失败')[:60]))
            json.dump(state, open(out_path, 'w', encoding='utf-8'), ensure_ascii=False)
            continue
        ok += 1
        for it in items:
            if not isinstance(it, dict):
                continue
            ref = grp[0]
            state['items'].append({
                '客户': _s(it.get('客户')) or ref['customer'],
                '类别': _s(it.get('类别')) or '其他',
                '标题': _s(it.get('标题')),
                '内容': _s(it.get('内容')),
                '依据': _s(it.get('依据')),
                '涉及货号': it.get('涉及货号') or ([ref['sku']] if ref['sku'] else []),
                '置信度': _s(it.get('置信度')) or '中',
                '单据编号': ref['doc'], '日期': ref['date'], '销售员': ref.get('owner') or '',
                '来源文件': ref['src'], '出现次数': ref.get('hits') or 1,
            })
            sys.stderr.write('  批 %d/%d ✓ 抽出 %d 条（累计 %d）\n'
                             % (done_batches, total_batches, len(items), len(state['items'])))
        for c in grp:
            state['by_doc'][c['id']] = 1
        json.dump(state, open(out_path, 'w', encoding='utf-8'), ensure_ascii=False)
    sys.stderr.write('  分片 %d 完成：成功 %d 批 ｜ 失败 %d 批 ｜ 累计抽出 %d 条\n' % (shard + 1, ok, bad, len(state['items'])))


def do_merge(shards):
    all_items = []
    for i in range(shards):
        p = os.path.join(OUT_DIR, '_erp_sr_extracted_s%d.json' % i)
        if os.path.exists(p):
            d = json.load(open(p, encoding='utf-8'))
            all_items += d.get('items') or []
    # 去重：客户 + 内容前 40 字
    seen, uniq = set(), []
    for it in all_items:
        k = (it.get('客户'), re.sub(r'\s+', '', (it.get('内容') or '')[:40]))
        if k in seen:
            continue
        seen.add(k)
        uniq.append(it)
    json.dump({'total': len(uniq), 'items': uniq}, open(MERGED, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    sys.stderr.write('  合并 %d 个分片 → %d 条抽出 ｜ 去重后 %d 条\n' % (shards, len(all_items), len(uniq)))


def do_filter():
    cands = load_all()
    json.dump({'total': len(cands), 'candidates': cands}, open(CAND, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    json.dump({'total': len(cands), 'candidates': cands}, open(INPUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    from collections import Counter
    print('  候选 %d 条 ｜ 覆盖客户 %d 个 ｜ 有归属人 %d 条' % (len(cands), len({c['customer'] for c in cands}), sum(1 for c in cands if c['owner'])))
    print('  出现次数分布 TOP:', Counter(min(c['hits'], 10) for c in cands).most_common(5))
    print('  重复率最高的要求:')
    for c in sorted(cands, key=lambda x: -x['hits'])[:8]:
        print('    %3d 次 × [%s] %s' % (c['hits'], c['customer'][:16], c['text'].replace('\n', ' / ')[:70]))
    print('  候选抽样:')
    for c in cands[:10]:
        print('    · [%s｜%s] %s' % (c['customer'][:14], c['date'], c['text'].replace('\n', ' / ')[:86]))


if __name__ == '__main__':
    a = sys.argv[1:]
    if '--filter' in a:
        do_filter()
    elif '--merge' in a:
        do_merge(int(a[a.index('--merge') + 1]))
    elif '--run' in a:
        sh = int(a[a.index('--shards') + 1]) if '--shards' in a else 1
        si = int(a[a.index('--shard') + 1]) if '--shard' in a else 0
        ba = int(a[a.index('--batch') + 1]) if '--batch' in a else 20
        li = int(a[a.index('--limit') + 1]) if '--limit' in a else 0
        do_run(limit=li, batch=ba, shard=si, shards=sh)
    else:
        print(__doc__)
