209 lines
9.5 KiB
Python
209 lines
9.5 KiB
Python
"""独立的部门归属规则库;不写入出库、人员或组织架构数据。"""
|
||
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}
|