#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""P1 后端补丁：活动 × 待办打通 + 拜访视图。

改动面（脚本/销售工作台服务.py）：
  A 加 import hashlib
  B 新增 P1 辅助块：订单索引缓存 / 订单分类 / 待办 upsert
  C _act_save 的索引字段清单加 channel（拜访视图靠索引算次数，不读全量活动文件）
  D 新增 GET /api/visit/stats（当月线下拜访次数 + 线上跟进次数 + 客户下钻）
  E 提交校验：channel 必填枚举 + order_refs（按索引 is_test 服务端分类）
  F rec 落 channel / order_refs
  G 提交成功 → 待办 upsert（只落已确认，幂等，绝不碰手工条/人的动作）

每处替换断言命中次数 == 1，任何一处不中即整体不落盘。
"""
import io
import os
import re
import shutil
import sys
import time

ROOT = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
F = os.path.join(ROOT, '脚本', '销售工作台服务.py')

# ── A ──
A_OLD = "import subprocess\nimport threading"
A_NEW = "import subprocess\nimport hashlib\nimport threading"

# ── B ──
B_HELPERS = '''

# ══════════ P1：活动 × 待办打通 + 拜访视图 ══════════
ORDERS_INDEX_FILE = os.path.join(DATA_DIR, 'orders_2026_index.json')
_ORDERS_CACHE = {'data': None, 'mtime': None}


def _orders_index():
    """订单索引（2.8MB，进程内缓存，按 mtime 失效）。"""
    try:
        mt = os.path.getmtime(ORDERS_INDEX_FILE)
    except OSError:
        return {}
    if _ORDERS_CACHE['data'] is None or _ORDERS_CACHE['mtime'] != mt:
        try:
            with open(ORDERS_INDEX_FILE, encoding='utf-8') as f:
                raw = json.load(f)
            _ORDERS_CACHE['data'] = {str(o.get('no')): o for o in (raw.get('orders') or [])}
            _ORDERS_CACHE['mtime'] = mt
        except Exception:
            return {}
    return _ORDERS_CACHE['data']


def _act_classify_orders(nos):
    """订单号列表 → [{no,kind,customer,date,in_index}]。

    kind 由服务端按索引 is_test 判定，**不让前端自报类型**（前端自报可造假/可填错）。
    """
    out = []
    for _no in (nos or []):
        _no = str(_no).strip()
        if not _no:
            continue
        h = _orders_index().get(_no)
        out.append({'no': _no, 'kind': ('test' if (h or {}).get('is_test') else 'ship'),
                    'customer': (h or {}).get('customer') or '',
                    'date': (h or {}).get('date') or '',
                    'in_index': bool(h)})
    return out


TD_SCOPE = {
    'internal_project': 'internal', 'internal_complaint': 'internal',
    'internal_test_plan': 'internal', 'internal_other': 'internal',
    'external_visit': 'external', 'external_test_req': 'external',
    'external_test_result': 'external', 'external_project': 'external',
    'external_appointment': 'external',
}


def _todo_fp(seed, uid):
    """指纹只锚 (seed, 稳定行 uid) —— 绝不锚会变的文本，否则改措辞会变出新条目。"""
    return hashlib.sha1(('%s#%s' % (seed, uid)).encode('utf-8')).hexdigest()[:16]


def _cbt_save(user, todos):
    """CloudBase todos 整体覆盖写（与前端 pushTodos 同形态；失败不阻断本地）。"""
    try:
        cmd = json.dumps({'update': 'todos',
                          'updates': [{'q': {'user': user},
                                       'u': {'$set': {'todos': todos,
                                                      'updated': datetime.now().strftime('%Y-%m-%dT%H:%M:%S.000Z')}}}]},
                         ensure_ascii=False)
        subprocess.run(['node', TCB_CLI, 'db', 'nosql', 'execute', '--command',
                        json.dumps([{'TableName': 'todos', 'CommandType': 'UPDATE', 'Command': cmd}],
                                   ensure_ascii=False),
                        '--json', '-e', CB_ENV], capture_output=True, text=True, timeout=120)
        return True
    except Exception:
        return False


def _act_sync_todos(rec, confirmed, draft_id):
    """把「销售提交前确认过的待办清单」落库。

    规则（用户 2026-10-07 定版）：
      · 生成 ≠ 落库：只落 confirmed=True 的条目；
      · 幂等：fp = sha1((draft_id|活动id) + '#' + 稳定行 uid)，重复提交只更新不新增；
      · 绝不覆盖手工条：只处理 source.kind == 'activity' 的记录；
      · 绝不抹掉人的动作：命中已有条目时只更新 text/type/scope/due/owner/tech_support，
        永不动 done / closed / closedBy / conclusion。
    """
    seed = (draft_id or '').strip() or rec['id']
    store = read_store()
    users = store.setdefault('users', {})
    created = updated = removed = skipped = 0
    touched = set()
    live = {}

    for c in (confirmed or []):
        uid = str(c.get('uid') or '').strip()
        text = str(c.get('text') or c.get('item') or '').strip()
        if not uid or not text or not c.get('confirmed'):
            skipped += 1
            continue
        owner = str(c.get('owner') or rec.get('submitted_by') or '').strip()
        if not owner:
            skipped += 1
            continue
        tp = c.get('type') if c.get('type') in TD_SCOPE else 'external_project'
        fp = _todo_fp(seed, uid)
        live[fp] = True
        lst = users.setdefault(owner, [])
        hit = None
        for t in lst:
            if t.get('fp') == fp:
                hit = t
                break
        src = {'kind': 'activity', 'activity_id': rec['id'], 'draft_id': (draft_id or ''),
               'customer': rec.get('customer', ''), 'channel': rec.get('channel', '')}
        if hit is not None:
            if (hit.get('source') or {}).get('kind') == 'activity':
                hit['text'] = text
                hit['type'] = tp
                hit['cat'] = tp
                hit['scope'] = TD_SCOPE[tp]
                hit['due'] = str(c.get('due') or '').strip()
                hit['owner'] = owner
                hit['tech_support'] = bool(c.get('tech_support'))
                hit['source'] = src
                hit['updated'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                updated += 1
                touched.add(owner)
            else:
                skipped += 1
        else:
            lst.insert(0, {
                'id': datetime.now().strftime('%y%m%d%H%M%S') + str(int(fp[:6], 16) % 1000).zfill(3),
                'text': text, 'done': False, 'type': tp, 'cat': tp, 'scope': TD_SCOPE[tp],
                'owner': owner, 'tech_support': bool(c.get('tech_support')),
                'client': rec.get('customer', ''), 'due': str(c.get('due') or '').strip(),
                'note': '', 'created': datetime.now().strftime('%Y-%m-%d'),
                'fp': fp, 'source': src,
            })
            created += 1
            touched.add(owner)

    # 活动派生条中，本轮清单已不含的 → 标 source_removed（不物理删除，留痕给人决定）
    for owner, lst in list(users.items()):
        if not isinstance(lst, list):
            continue
        for t in lst:
            s = t.get('source') or {}
            if s.get('kind') != 'activity':
                continue
            same = (s.get('activity_id') == rec['id']) or (draft_id and s.get('draft_id') == draft_id)
            if same and t.get('fp') and t['fp'] not in live and not t.get('source_removed'):
                t['source_removed'] = True
                removed += 1
                touched.add(owner)

    if touched:
        store['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        write_store(store)
        for o in touched:
            _cbt_save(o, users.get(o) or [])
    return {'created': created, 'updated': updated, 'removed': removed,
            'skipped': skipped, 'owners': sorted(touched)}

'''

B_OLD = "def _act_save(rec):\n    os.makedirs(ACT_DIR, exist_ok=True)"

# ── C ──
C_OLD = "'customer', 'contact', 'date', 'order_no', 'sku_count', 'att_count',"
C_NEW = "'customer', 'contact', 'date', 'channel', 'order_no', 'sku_count', 'att_count',"

# ── D ──
D_VISIT = '''        # ══════════ P1：拜访视图统计（当月线下拜访次数 / 线上跟进次数）══════════
        if path == '/api/visit/stats':
            _au = self._auth_user()
            if not _au:
                self._send_json({'ok': False, 'error': '登录已过期，请重新登录'}, 401)
                return
            from urllib.parse import parse_qs
            _qs = {k: v[0] for k, v in parse_qs(self.path.split('?', 1)[1]).items()} if '?' in self.path else {}
            month = ((_qs.get('month') or '').strip() or datetime.now().strftime('%Y-%m'))[:7]
            if not re.match(r'^\\d{4}-\\d{2}$', month):
                self._send_json({'ok': False, 'error': 'month 需为 YYYY-MM'}, 400)
                return
            vis = _act_visible_sales(_au)
            rows = [x for x in _act_load_index()
                    if vis is None or (x.get('submitted_by') in vis) or (x.get('sales') in vis)]
            monthly = [x for x in rows if (x.get('date') or '').startswith(month)]
            offline = online = noclass = 0
            cust = {}
            for x in monthly:
                ch = (x.get('channel') or '').strip()
                if ch == 'offline':
                    offline += 1
                elif ch == 'online':
                    online += 1
                else:
                    noclass += 1
                c = (x.get('customer') or '').strip() or '(未填客户)'
                d = cust.setdefault(c, {'customer': c, 'offline': 0, 'online': 0, 'other': 0, 'acts': []})
                d['offline' if ch == 'offline' else ('online' if ch == 'online' else 'other')] += 1
                d['acts'].append({'id': x.get('id', ''), 'date': x.get('date', ''), 'channel': ch,
                                  'type': x.get('type', ''), 'sales': x.get('sales', '')})
            for d in cust.values():
                d['total'] = d['offline'] + d['online'] + d['other']
                d['acts'].sort(key=lambda a: a.get('date') or '', reverse=True)
            self._send_json({'ok': True, 'month': month,
                             'offline_count': offline, 'online_count': online,
                             'unclassified': noclass, 'total': len(monthly),
                             'customer_count': len(cust),
                             'months': sorted({(x.get('date') or '')[:7] for x in rows if x.get('date')},
                                              reverse=True),
                             'customers': sorted(cust.values(), key=lambda d: -d['total'])})
            return

'''
D_OLD = "        # ══════════ 活动录入：订单 / SKU / 列表 / 详情 / 附件地址 ══════════\n        if path.startswith('/api/activity/') and path != '/api/activity/options':"

# ── E ──
E_OLD = """            typ = (payload.get('type') or '').strip()
            if typ not in ('项目跟进', '采购跟进', '客户档案完善'):
                self._send_json({'ok': False, 'error': '活动类型必须是「项目跟进」/「采购跟进」/「客户档案完善」'}, 400)
                return
"""
E_NEW = """            typ = (payload.get('type') or '').strip()
            if typ not in ('项目跟进', '采购跟进', '客户档案完善'):
                self._send_json({'ok': False, 'error': '活动类型必须是「项目跟进」/「采购跟进」/「客户档案完善」'}, 400)
                return
            # ── P1：活动形式必填（下拉，只认线上/线下）──
            ch = (payload.get('channel') or '').strip()
            if ch not in ('offline', 'online'):
                self._send_json({'ok': False, 'error': '请选择活动形式：线下拜访 / 线上跟进（P1 起必填）'}, 400)
                return
            # ── P1：关联订单（发货单 / 测试单，类型服务端按 is_test 判定）──
            order_refs = _act_classify_orders(payload.get('order_refs'))
            _bad_refs = [o['no'] for o in order_refs if not o.get('in_index')]
            order_refs = [o for o in order_refs if o.get('in_index')]
"""

# ── F ──
F_OLD = """                'date': (payload.get('date') or '').strip() or datetime.now().strftime('%Y-%m-%d'),
                'opportunities': opportunities,"""
F_NEW = """                'date': (payload.get('date') or '').strip() or datetime.now().strftime('%Y-%m-%d'),
                'channel': ch,
                'order_refs': order_refs,
                'order_ref_nos': [o['no'] for o in order_refs],
                'opportunities': opportunities,"""

# ── G ──
G_OLD = """            _act_save(rec)
            cb_ok, cb_err = _act_cb_insert(rec)
            self._send_json({'ok': True, 'id': rec['id'], 'feishu_text': rec['feishu_text'],
                             'cb': cb_ok, 'cb_err': ('' if cb_ok else cb_err)})
            return"""
G_NEW = """            _act_save(rec)
            cb_ok, cb_err = _act_cb_insert(rec)
            # ── P1：活动确认清单 → 待办（生成≠落库，只落已确认；幂等；不碰手工条）──
            try:
                todo_res = _act_sync_todos(rec, payload.get('todos') or [],
                                           (payload.get('draft_id') or '').strip())
            except Exception as _te:
                import traceback
                traceback.print_exc()
                todo_res = {'error': str(_te)}
            self._send_json({'ok': True, 'id': rec['id'], 'feishu_text': rec['feishu_text'],
                             'channel': rec['channel'], 'order_refs': rec['order_refs'],
                             'unmatched_orders': _bad_refs,
                             'cb': cb_ok, 'cb_err': ('' if cb_ok else cb_err),
                             'todos': todo_res})
            return"""

PAIRS = [
    ('A import hashlib', A_OLD, A_NEW),
    ('B P1 辅助块', B_OLD, B_HELPERS + '\n' + B_OLD),
    ('C 索引加 channel', C_OLD, C_NEW),
    ('D 拜访视图端点', D_OLD, D_VISIT + D_OLD),
    ('E channel+order_refs 校验', E_OLD, E_NEW),
    ('F rec 落新字段', F_OLD, F_NEW),
    ('G 待办 upsert 接线', G_OLD, G_NEW),
]


def main():
    apply = '--apply' in sys.argv
    s = io.open(F, encoding='utf-8').read()
    print('P1 后端补丁 —— %s\n' % ('APPLY' if apply else 'DRY-RUN'))
    ok = True
    for label, old, new in PAIRS:
        n = s.count(old)
        flag = '✅' if n == 1 else '❌'
        if n != 1:
            ok = False
        print('  %s %-28s 命中 %d' % (flag, label, n))
        if n == 1:
            s = s.replace(old, new, 1)
    if not ok:
        print('\n有未命中 → 不落盘')
        return 1
    if apply:
        ts = time.strftime('%Y%m%d-%H%M%S')
        bak = os.path.join(ROOT, '_bak_p1_backend_%s' % ts)
        os.makedirs(bak, exist_ok=True)
        shutil.copy2(F, os.path.join(bak, os.path.basename(F)))
        io.open(F, 'w', encoding='utf-8').write(s)
        print('\n已写（原文件备份在 %s）' % bak)
    else:
        print('\n干跑通过，未落盘。')
    return 0


sys.exit(main())
