"""独立的部门归属规则库;不写入出库、人员或组织架构数据。""" import json import re import unicodedata from services.db import get_db, query KINDS = ('platform', 'brand', 'nonbrand', 'shop') FIELDS = { 'platform': ('department', 'shop_keywords', 'source_keywords'), 'brand': ('keyword', 'department', 'mode', 'pdd_department', 'overseas_department', 'notes', 'reference_count'), 'nonbrand': ('name', 'reason', 'guidance'), 'shop': ('shop', 'row_count', 'product_count', 'department', 'layer'), } MODES = ('fixed', 'split', 'pdd_only') def ensure_schema(): query("""CREATE TABLE IF NOT EXISTS department_matching_records ( id BIGINT PRIMARY KEY AUTO_INCREMENT, kind VARCHAR(20) NOT NULL, payload JSON NOT NULL, priority INT NOT NULL DEFAULT 100, enabled TINYINT NOT NULL DEFAULT 1, source_file VARCHAR(255) NOT NULL DEFAULT '', source_row INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_kind (kind, priority, id) ) CHARACTER SET utf8mb4""") def records(): rows = query('SELECT * FROM department_matching_records ORDER BY priority,id') for row in rows: if isinstance(row['payload'], str): row['payload'] = json.loads(row['payload']) return rows def normalized(text): return unicodedata.normalize('NFKC', str(text or '')).strip().casefold() def tokens(text): return [normalized(v) for v in re.split(r'[,,、\n]+', str(text or '')) if normalized(v)] def validate_record(data): kind = data.get('kind') if kind not in KINDS: raise ValueError('记录类型不正确') raw = data.get('payload') if not isinstance(raw, dict): raise ValueError('请填写规则内容') payload = {k: str(raw.get(k, '') if raw.get(k) is not None else '').strip() for k in FIELDS[kind]} if any(len(v) > 4000 for v in payload.values()): raise ValueError('单字段内容不能超过4000字') required = {'platform': ('department',), 'brand': ('keyword', 'mode'), 'nonbrand': ('name',), 'shop': ('shop', 'department')}[kind] if any(not payload[k] for k in required): raise ValueError('请填写必填字段') if kind == 'platform' and not (tokens(payload['shop_keywords']) or tokens(payload['source_keywords'])): raise ValueError('店铺关键词和来源关键词至少填写一项') if kind == 'brand': if payload['mode'] not in MODES: raise ValueError('品牌匹配方式不正确') if payload['mode'] in ('fixed', 'split') and not payload['department']: raise ValueError('请填写默认归属') if payload['mode'] in ('split', 'pdd_only') and not payload['pdd_department']: raise ValueError('请填写拼多多归属') try: priority = int(data.get('priority', 100)) except (ValueError, TypeError): raise ValueError('优先级必须为整数') if not 0 <= priority <= 100000: raise ValueError('优先级范围为0至100000,数字越小越优先') enabled = data.get('enabled', True) if enabled not in (True, False, 0, 1): raise ValueError('启用状态不正确') return kind, payload, priority, int(bool(enabled)) def match_department(shop='', source='', rows=None): """品牌条件匹配后回落平台层;来源仅在店铺为空时精确匹配。""" shop, source = normalized(shop), normalized(source) rows = sorted((r for r in (records() if rows is None else rows) if r['enabled']), key=lambda r: (r['priority'], r['id'])) def result(row, department, layer, reason): return {'department': department, 'layer': layer, 'reason': reason, 'rule_id': row['id']} if shop: for row in rows: if row['kind'] != 'brand': continue p = row['payload'] keyword = normalized(p['keyword']) compact = lambda value: re.sub(r'\s+', '', value) if keyword in ('dr jeveital', 'drjeveital', 'jeveital'): hit = compact(keyword) in compact(shop) elif keyword == 'aromat': hit = bool(re.search(r'aromat(?![a-z])', shop)) else: hit = bool(keyword and keyword in shop) if not hit: continue is_pdd = '拼多多' in shop mode = p['mode'] if mode == 'pdd_only' and not is_pdd: continue department = p['pdd_department'] if is_pdd and mode != 'fixed' else p['department'] if mode == 'split' and p.get('overseas_department') and any( word in shop for word in ('京东海外', '京东国际')): department = p['overseas_department'] return result(row, department, '品牌层', '店铺命中品牌关键词:' + p['keyword']) candidates = [] for row in rows: if row['kind'] == 'platform': for keyword in tokens(row['payload']['shop_keywords']): if keyword in shop: candidates.append((-len(keyword), row['priority'], row['id'], keyword, row)) if candidates: _, _, _, keyword, row = min(candidates, key=lambda item: item[:3]) return result(row, row['payload']['department'], '平台层', '店铺命中平台关键词:' + keyword) elif source: for row in rows: if row['kind'] == 'platform' and source in tokens(row['payload']['source_keywords']): return result(row, row['payload']['department'], '来源兜底', '店铺为空,数据来源精确命中:' + source) return {'department': '', 'layer': '未匹配', 'reason': '未命中规则,请维护对应规则', 'rule_id': None} def check_samples(rows=None): rows = records() if rows is None else rows samples = [] for row in rows: if row['kind'] == 'shop': p = row['payload'] matched = match_department(p['shop'], rows=rows) samples.append({'id': row['id'], 'shop': p['shop'], 'expected': p['department'], 'actual': matched['department'], 'passed': matched['department'] == p['department'], 'reason': matched['reason']}) return {'total': len(samples), 'passed': sum(r['passed'] for r in samples), 'mismatches': [r for r in samples if not r['passed']]} def workbook_records(path): from openpyxl import load_workbook workbook = load_workbook(path, data_only=True, read_only=True) result = [] try: for sheet, kind in (('平台层规则', 'platform'), ('品牌层规则', 'brand'), ('非品牌清单', 'nonbrand'), ('店铺归属对照表', 'shop')): for number, values in enumerate(workbook[sheet].iter_rows(min_row=2, values_only=True), 2): if not any(v is not None for v in values): continue v = [str(value).strip() if value is not None else '' for value in values] if kind == 'platform': payload = dict(zip(FIELDS[kind], v)) elif kind == 'brand': keyword, department, match_type, notes, count = v mode, pdd, overseas = 'fixed', '', '' if keyword in ('QSIMELR', 'MUTO'): mode, department, pdd = 'split', '灵动', '雪狼' overseas = '雪狼' if keyword == 'MUTO' else '' elif keyword in ('UOUONE', 'WOPUKA', 'FOCOOL', 'AVAA'): mode, department, pdd = 'pdd_only', '', '春跃' payload = dict(zip(FIELDS[kind], (keyword, department, mode, pdd, overseas, notes, count))) payload['notes'] = match_type + ';' + notes else: payload = dict(zip(FIELDS[kind], v)) k, payload, priority, enabled = validate_record({'kind': kind, 'payload': payload, 'priority': number * 10}) result.append({'kind': k, 'payload': payload, 'priority': priority, 'enabled': enabled, 'source_row': number}) finally: workbook.close() return result def import_workbook(path): """首次导入;已有记录则拒绝覆盖,重复运行安全。""" from pathlib import Path rows = workbook_records(path) ensure_schema() conn = get_db() try: conn.begin() with conn.cursor() as cur: cur.execute("SELECT GET_LOCK('department_matching_import', 10) AS acquired") if not cur.fetchone()['acquired']: raise ValueError('另一个导入正在进行') cur.execute('SELECT COUNT(*) AS n FROM department_matching_records') if cur.fetchone()['n']: raise ValueError('规则库已有数据,未覆盖;请在页面维护') cur.executemany('''INSERT INTO department_matching_records (kind,payload,priority,enabled,source_file,source_row) VALUES (%s,%s,%s,%s,%s,%s)''', [(r['kind'], json.dumps(r['payload'], ensure_ascii=False), r['priority'], r['enabled'], Path(path).name, r['source_row']) for r in rows]) conn.commit() except Exception: conn.rollback() raise finally: with conn.cursor() as cur: cur.execute("SELECT RELEASE_LOCK('department_matching_import')") conn.close() return {kind: sum(r['kind'] == kind for r in rows) for kind in KINDS}