dy-dashboard/services/department_matching.py
2026-10-29 18:24:00 +08:00

209 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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