下午的优化

This commit is contained in:
Codex 2026-10-29 18:24:00 +08:00
parent e7b2dd64f1
commit 8ea8a05459
11 changed files with 581 additions and 3 deletions

2
app.py
View File

@ -137,7 +137,7 @@ def spa_fallback(path):
SPA_PATHS = {
'knowledge', 'planning', 'products', 'bom', 'purchase',
'orders', 'inventory', 'warehouse', 'suppliers',
'users', 'organization', 'data', 'config', 'logs', 'permissions',
'users', 'organization', 'department-matching', 'data', 'config', 'logs', 'permissions',
'login-records',
'contract-templates', 'personnel',
'collaboration', 'spot', 'ai-analysis', 'product-order',

View File

@ -15,6 +15,7 @@ from routes.ai import ai_bp
from routes.sku_forecast import sku_forecast_bp
from routes.spot import spot_bp
from routes.organization import organization_bp
from routes.department_matching import department_matching_bp
def register_blueprints(app):
app.register_blueprint(auth_bp)
@ -32,6 +33,7 @@ def register_blueprints(app):
app.register_blueprint(sku_forecast_bp)
app.register_blueprint(spot_bp)
app.register_blueprint(organization_bp)
app.register_blueprint(department_matching_bp)
__all__ = ['register_blueprints', 'auth_bp', 'dashboard_bp', 'products_bp',
'inventory_bp', 'planning_bp', 'purchase_bp', 'warehouse_bp',

View File

@ -110,6 +110,7 @@ PAGE_FEATURES = {
'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','name':'新增供应商'}],
'knowledge': [{'id':'view','name':'查看'},{'id':'create','name':'新建文章'},{'id':'edit','name':'编辑文章'},{'id':'delete','name':'删除文章'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'lint','name':'运行检查'},{'id':'graph','name':'关系图谱'}],
'organization': [{'id':'view','name':'查看'},{'id':'sync','name':'同步钉钉组织架构'}],
'department_matching': [{'id':'view','name':'查看与匹配测试'},{'id':'create','name':'新增规则'},{'id':'edit','name':'编辑规则'},{'id':'delete','name':'删除规则'}],
'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}],
'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}],
'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}],

View File

@ -0,0 +1,82 @@
"""部门匹配规则维护和只读试算。"""
import json
from flask import Blueprint, jsonify, request
from routes.auth import login_required, operation_required, role_required
from services.db import query, add_operation_log
from services.department_matching import records, validate_record, match_department, check_samples
department_matching_bp = Blueprint('department_matching', __name__)
@department_matching_bp.route('/api/department-matching/records')
@login_required
def list_records():
if not role_required('department_matching'):
return jsonify(code=403, msg='无部门匹配管理查看权限'), 403
return jsonify(code=0, data=records())
@department_matching_bp.route('/api/department-matching/save', methods=['POST'])
@login_required
def save_record():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify(code=400, msg='请求格式不正确'), 400
if not operation_required('department_matching', 'edit' if data.get('id') else 'create'):
return jsonify(code=403, msg='无规则维护权限'), 403
try:
kind, payload, priority, enabled = validate_record(data)
row_id = int(data.get('id') or 0)
old = query('SELECT * FROM department_matching_records WHERE id=%s', (row_id,)) if row_id else []
if row_id and (not old or old[0]['kind'] != kind):
return jsonify(code=404, msg='记录不存在或类型不正确'), 404
if row_id:
query('UPDATE department_matching_records SET payload=%s,priority=%s,enabled=%s WHERE id=%s',
(json.dumps(payload, ensure_ascii=False), priority, enabled, row_id))
else:
row_id = query('INSERT INTO department_matching_records (kind,payload,priority,enabled) VALUES (%s,%s,%s,%s)',
(kind, json.dumps(payload, ensure_ascii=False), priority, enabled))
add_operation_log('编辑部门匹配规则' if old else '新增部门匹配规则', f'记录{row_id},类型{kind}',
page='department_matching', object_id=str(row_id),
old_values=old[0] if old else None, new_values=data)
return jsonify(code=0, msg='保存成功', id=row_id)
except (ValueError, TypeError) as exc:
return jsonify(code=400, msg=str(exc)), 400
@department_matching_bp.route('/api/department-matching/delete', methods=['POST'])
@login_required
def delete_record():
if not operation_required('department_matching', 'delete'):
return jsonify(code=403, msg='无删除权限'), 403
try:
row_id = int((request.get_json(silent=True) or {}).get('id', 0))
except (ValueError, TypeError):
return jsonify(code=400, msg='记录编号不正确'), 400
old = query('SELECT * FROM department_matching_records WHERE id=%s', (row_id,))
if not old:
return jsonify(code=404, msg='记录不存在'), 404
query('DELETE FROM department_matching_records WHERE id=%s', (row_id,))
add_operation_log('删除部门匹配规则', f'记录{row_id}', page='department_matching',
object_id=str(row_id), old_values=old[0])
return jsonify(code=0, msg='删除成功')
@department_matching_bp.route('/api/department-matching/test', methods=['POST'])
@login_required
def test_match():
if not role_required('department_matching'):
return jsonify(code=403, msg='无查看权限'), 403
data = request.get_json(silent=True) or {}
if not isinstance(data, dict) or any(not isinstance(data.get(k, ''), str) or len(data.get(k, '')) > 4000 for k in ('shop', 'source')):
return jsonify(code=400, msg='店铺和来源必须为4000字以内的文本'), 400
return jsonify(code=0, data=match_department(data.get('shop', ''), data.get('source', '')))
@department_matching_bp.route('/api/department-matching/check', methods=['POST'])
@login_required
def check_matching():
if not role_required('department_matching'):
return jsonify(code=403, msg='无查看权限'), 403
return jsonify(code=0, data=check_samples())

View File

@ -0,0 +1,29 @@
"""首次初始化部门规则,默认只检查,--apply 才写入。"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('workbook')
parser.add_argument('--apply', action='store_true')
parser.add_argument('--local', action='store_true')
args = parser.parse_args()
if args.local:
import config
config.DB_CONFIG.update(user='dy_test', password=Path('.local-test-db-password').read_text().strip())
from services.department_matching import workbook_records, import_workbook, check_samples
rows = workbook_records(args.workbook)
for i, row in enumerate(rows, 1):
row['id'] = i
print(json.dumps({'records': len(rows), 'validation': check_samples(rows)}, ensure_ascii=False))
if args.apply:
print(json.dumps({'imported': import_workbook(args.workbook)}, ensure_ascii=False))
if __name__ == '__main__':
main()

View File

@ -20,6 +20,8 @@ from services.audit_schema import ensure_audit_schema, infer_page, infer_action,
# 明确非数据变更的接口(登录/退出/导出/知识库自检/合同生成/查询),不做审计
EXCLUDED_PATHS = frozenset({
'/api/department-matching/test', # 只读匹配试算
'/api/department-matching/check', # 只读对照表核验
'/api/login', # 登录(无会话上下文,且登录日志单独直接写入)
'/api/logout', # 退出(仅清理会话,不产生数据变更)
'/api/inventory/export', # 库存导出(生成文件,不修改业务数据)

View File

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

View File

@ -1,7 +1,7 @@
// 蝶雅采购智能体平台 - 前端逻辑
let charts = {};
// 页面缓存:切换菜单时不销毁页面状态
var CACHE_VERSION = 100;
var CACHE_VERSION = 102;
if (!window._pageCache) window._pageCache = {};
if (!window._pageDataCache) window._pageDataCache = {};
if (!window._pageReloaders) window._pageReloaders = {};
@ -34,6 +34,7 @@ const PAGE_LOADERS = {
'products': loadProductsPage,
'brand_management': loadBrandManagementPage,
'organization': loadOrganizationPage,
'department_matching': loadDepartmentMatchingPage,
'users': loadUsersPage,
'logs': loadLogsPage,
'login_records': loadLoginRecordsPage,
@ -6060,6 +6061,7 @@ var PAGE_MAP = {
'/orders/packaging': 'orders_packaging',
'/orders/finished': 'orders_finished',
'/organization': 'organization',
'/department-matching': 'department_matching',
'/users': 'users',
'/login-records': 'login_records',
'/logs': 'logs',
@ -6200,6 +6202,7 @@ window._hideNoPermNavItems = function() {
{page:'knowledge', sel:'a.nav-item[href="/knowledge"]', sub:false},
{page:'contract_templates', sel:'a.nav-sub-item[href="/contract-templates"]', sub:true},
{page:'organization', sel:'a.nav-sub-item[href="/organization"]', sub:true},
{page:'department_matching', sel:'a.nav-sub-item[href="/department-matching"]', sub:true},
{page:'users', sel:'a.nav-sub-item[href="/users"]', sub:true},
{page:'permissions', sel:'a.nav-sub-item[href="/permissions"]', sub:true},
{page:'login_records', sel:'a.nav-sub-item[href="/login-records"]', sub:true},

View File

@ -0,0 +1,139 @@
// 部门匹配独立规则库,不对业务数据执行回写。
var departmentMatchingState = {rows: [], kind: 'platform', page: 1, search: ''};
var departmentMatchingKinds = {platform: '平台层规则', brand: '品牌层规则', nonbrand: '非品牌清单', shop: '店铺归属对照表'};
var departmentMatchingFields = {
platform: [['department', '归属部门(必填)'], ['shop_keywords', '店铺关键词(逗号分隔)'], ['source_keywords', '空店铺来源(逗号分隔,精确匹配)']],
brand: [['keyword', '品牌关键词(必填)'], ['department', '默认归属'], ['mode', '匹配方式(必填)'], ['pdd_department', '拼多多归属'], ['overseas_department', '京东海外/国际归属'], ['notes', '判定说明']],
nonbrand: [['name', '名称(必填)'], ['reason', '为什么不是品牌'], ['guidance', '判定说明']],
shop: [['shop', '店铺(必填)'], ['department', '预期归属(必填)'], ['layer', '参考命中层级']]
};
var departmentMatchingModes = {fixed: '固定归属(全平台)', split: '按平台分配归属', pdd_only: '拼多多专属,其他回落平台层'};
async function departmentMatchingRequest(path, data) {
var response = await fetch('/api/department-matching/' + path, data === undefined ? {} : {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)
});
var result = await response.json();
if (!response.ok || result.code !== 0) throw new Error(result.msg || '请求失败');
return result;
}
async function loadDepartmentMatchingPage(container) {
container.innerHTML = `<style>
.dm-table{border-collapse:collapse;width:100%;font-size:13px}
.dm-table th,.dm-table td{border:1px solid var(--border,#e5e7eb);padding:10px 12px;text-align:left;vertical-align:top;overflow-wrap:anywhere;max-width:360px}
.dm-table th{background:var(--bg-secondary,#f5f6f8);position:sticky;top:0;z-index:1;white-space:nowrap}
.dm-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}
.dm-panel{padding:18px;margin-bottom:16px}.dm-help{color:var(--text-light,#6b7280);font-size:12px;line-height:1.8}
.dm-scroll{overflow:auto;max-height:calc(100vh - 470px);min-height:200px}
.dm-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.dm-fields label{display:block;font-size:13px;margin-bottom:6px}.dm-fields .form-input{width:100%;box-sizing:border-box}
@media(max-width:700px){.dm-fields{grid-template-columns:1fr}}
</style><div class="page-header"><h1>部门匹配管理</h1></div>
<div class="card dm-panel"><strong>匹配测试</strong><div class="dm-help">品牌层优先 → 平台关键词按具体程度匹配 → 店铺为空时按来源精确兜底。仅试算归属,不修改业务数据。</div>
<div class="dm-toolbar"><input id="dmShop" class="form-input" style="width:360px;max-width:100%" placeholder="店铺名称" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
<input id="dmSource" class="form-input" style="width:230px;max-width:100%" placeholder="数据来源(店铺为空时使用)" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
<button class="btn btn-primary" onclick="testDepartmentMatching()">测试匹配</button><button class="btn" onclick="checkDepartmentMatching()">核验店铺对照表</button></div>
<div id="dmResult" role="status" class="dm-help">请输入店铺或数据来源进行测试。</div></div>
<div class="card dm-panel"><div id="dmTabs" class="dm-toolbar"></div>
<div class="dm-toolbar"><input id="dmSearch" class="form-input" style="width:300px" placeholder="搜索关键词、归属、店铺或说明" onkeydown="if(event.key==='Enter')searchDepartmentMatching(false)">
<button class="btn btn-primary" onclick="searchDepartmentMatching(false)">搜索</button><button class="btn" onclick="searchDepartmentMatching(true)">重置</button>
<button id="dmCreate" class="btn btn-primary" style="margin-left:auto;display:none" onclick="editDepartmentMatching(0)">新增</button></div>
<div class="dm-help" style="margin-bottom:10px">非品牌清单用于解释排除项;归属通过平台规则判定。平台层先比较关键词长度,长度相同时优先级数字越小越优先。</div>
<div id="dmTable" class="dm-scroll">正在加载…</div><div id="dmPager" class="dm-toolbar" style="justify-content:center"></div></div>`;
try {
var result = await departmentMatchingRequest('records');
departmentMatchingState.rows = result.data;
renderDepartmentMatching();
} catch (err) { document.getElementById('dmTable').textContent = err.message; }
}
function renderDepartmentMatching() {
var state = departmentMatchingState;
var tabs = document.getElementById('dmTabs');
if (!tabs) return;
tabs.innerHTML = Object.keys(departmentMatchingKinds).map(function(kind) {
return '<button class="btn ' + (state.kind === kind ? 'btn-primary' : '') + '" onclick="selectDepartmentMatchingTab(\'' + kind + '\')">' + departmentMatchingKinds[kind] + '(' + state.rows.filter(function(r){return r.kind === kind;}).length + ')</button>';
}).join('');
document.getElementById('dmCreate').style.display = window._hasPagePerm('department_matching', 'create') ? '' : 'none';
document.getElementById('dmSearch').value = state.search;
var rows = state.rows.filter(function(row) {
return row.kind === state.kind && (!state.search || departmentMatchingFields[row.kind].map(function(f){return row.payload[f[0]] || '';}).join(' ').toLowerCase().includes(state.search.toLowerCase()));
});
var pages = Math.max(1, Math.ceil(rows.length / 20));
state.page = Math.min(Math.max(state.page, 1), pages);
var fields = departmentMatchingFields[state.kind];
var canEdit = window._hasPagePerm('department_matching', 'edit');
var canDelete = window._hasPagePerm('department_matching', 'delete');
var rule = state.kind === 'platform' || state.kind === 'brand';
var showPriority = state.kind === 'platform';
var heads = '<th>序号</th>' + fields.map(function(f){return '<th>' + escHtml(f[1].replace('(必填)','')) + '</th>';}).join('') + (showPriority ? '<th>优先级</th>' : '') + (rule ? '<th>状态</th>' : '') + (canEdit || canDelete ? '<th>操作</th>' : '');
var body = rows.slice((state.page - 1) * 20, state.page * 20).map(function(row, i) {
return '<tr><td>' + ((state.page - 1) * 20 + i + 1) + '</td>' + fields.map(function(f) {
var value = row.payload[f[0]] || '';
if (f[0] === 'mode') value = departmentMatchingModes[value] || value;
return '<td>' + escHtml(String(value)) + '</td>';
}).join('') + (showPriority ? '<td>' + row.priority + '</td>' : '') + (rule ? '<td>' + (row.enabled ? '启用' : '停用') + '</td>' : '') + (canEdit || canDelete ? '<td style="white-space:nowrap">' +
(canEdit ? '<button class="btn btn-sm" onclick="editDepartmentMatching(' + row.id + ')">编辑</button> ' : '') +
(canDelete ? '<button class="btn btn-sm" style="color:#dc2626" onclick="deleteDepartmentMatching(' + row.id + ')">删除</button>' : '') + '</td>' : '') + '</tr>';
}).join('');
document.getElementById('dmTable').innerHTML = '<table class="dm-table"><thead><tr>' + heads + '</tr></thead><tbody>' + (body || '<tr><td colspan="15">暂无记录</td></tr>') + '</tbody></table>';
document.getElementById('dmPager').innerHTML = '<span>共 ' + rows.length + ' 条,每页20条</span><button class="btn" onclick="pageDepartmentMatching(-1)" ' + (state.page === 1 ? 'disabled' : '') + '>上一页</button><span>' + state.page + ' / ' + pages + '</span><button class="btn" onclick="pageDepartmentMatching(1)" ' + (state.page === pages ? 'disabled' : '') + '>下一页</button><input id="dmJump" class="form-input" type="number" min="1" max="' + pages + '" style="width:70px" value="' + state.page + '" onkeydown="if(event.key===\'Enter\')pageDepartmentMatching(0)"><button class="btn" onclick="pageDepartmentMatching(0)">跳转</button>';
}
function selectDepartmentMatchingTab(kind) { departmentMatchingState.kind=kind; departmentMatchingState.page=1; renderDepartmentMatching(); }
function searchDepartmentMatching(reset) { departmentMatchingState.search=reset?'':document.getElementById('dmSearch').value.trim(); departmentMatchingState.page=1; renderDepartmentMatching(); }
function pageDepartmentMatching(delta) {
departmentMatchingState.page = delta ? departmentMatchingState.page + delta : (parseInt(document.getElementById('dmJump').value,10) || 1);
renderDepartmentMatching();
}
function editDepartmentMatching(id) {
var row = departmentMatchingState.rows.find(function(r){return r.id === id;}) || {id:0, kind:departmentMatchingState.kind, payload:{}, priority:100, enabled:1};
document.getElementById('dmEditModal')?.remove();
var modal = document.createElement('div'); modal.id='dmEditModal'; modal.className='modal-overlay'; modal.style.cssText='display:flex;z-index:15000';
var fields = departmentMatchingFields[row.kind].map(function(f) {
var value = row.payload[f[0]] || '';
var control;
if (f[0] === 'mode') control = '<select class="form-input" data-dm-field="mode">' + Object.keys(departmentMatchingModes).map(function(mode) {return '<option value="' + mode + '" ' + (value === mode ? 'selected' : '') + '>' + departmentMatchingModes[mode] + '</option>';}).join('') + '</select>';
else control = '<textarea class="form-input" rows="' + (['notes','guidance','reason'].includes(f[0]) ? 3 : 2) + '" maxlength="4000" data-dm-field="' + f[0] + '">' + escHtml(String(value)) + '</textarea>';
return '<div><label>' + escHtml(f[1]) + '</label>' + control + '</div>';
}).join('');
var rule = row.kind === 'brand' || row.kind === 'platform';
modal.innerHTML = '<div class="modal" style="width:min(760px,94vw);max-height:90vh;display:flex;flex-direction:column"><div class="modal-header"><h3>' + (id ? '编辑' : '新增') + departmentMatchingKinds[row.kind] + '</h3><button class="modal-close" onclick="document.getElementById(\'dmEditModal\').remove()">×</button></div><form id="dmEditForm" style="display:flex;flex-direction:column;min-height:0"><div class="modal-body" style="overflow:auto"><div class="dm-fields">' + fields + (rule ? '<div><label>优先级(0–100000,数字越小越优先)</label><input id="dmPriority" type="number" min="0" max="100000" required class="form-input" value="' + row.priority + '"></div><div><label>状态</label><select id="dmEnabled" class="form-input"><option value="1">启用</option><option value="0" ' + (!row.enabled ? 'selected' : '') + '>停用</option></select></div>' : '') + '</div><div class="dm-help" style="margin-top:12px">品牌关键词取自店铺名称。按平台分配时,拼多多优先使用拼多多归属,京东海外/国际其次,其他使用默认归属。非品牌条目用于说明,不独立覆盖归属。</div><div id="dmEditError" style="color:#dc2626;margin-top:8px" role="alert"></div></div><div class="modal-footer"><button type="button" class="btn" onclick="document.getElementById(\'dmEditModal\').remove()">取消</button><button id="dmSave" type="submit" class="btn btn-primary">保存</button></div></form></div>';
if (row.kind === 'brand') modal.querySelector('#dmPriority').parentElement.remove();
document.body.appendChild(modal);
document.getElementById('dmEditForm').onsubmit = async function(event) {
event.preventDefault();
var payload=Object.assign({},row.payload); modal.querySelectorAll('[data-dm-field]').forEach(function(el){payload[el.dataset.dmField]=el.value;});
var button=document.getElementById('dmSave'); button.disabled=true;
try {
await departmentMatchingRequest('save', {id:row.id, kind:row.kind, payload:payload, priority:row.kind==='platform'?Number(document.getElementById('dmPriority').value):row.priority, enabled:rule?Number(document.getElementById('dmEnabled').value):1});
departmentMatchingState.rows=(await departmentMatchingRequest('records')).data;
modal.remove(); renderDepartmentMatching();
} catch(err) { document.getElementById('dmEditError').textContent=err.message; button.disabled=false; }
};
}
async function deleteDepartmentMatching(id) {
if (!confirm('确认删除此记录?删除规则会影响后续匹配测试结果。')) return;
try { await departmentMatchingRequest('delete',{id:id}); departmentMatchingState.rows=(await departmentMatchingRequest('records')).data; renderDepartmentMatching(); }
catch(err) { alert(err.message); }
}
async function testDepartmentMatching() {
var el=document.getElementById('dmResult');
try {
var data=(await departmentMatchingRequest('test',{shop:document.getElementById('dmShop').value, source:document.getElementById('dmSource').value})).data;
el.textContent='归属:' + (data.department || '未匹配') + ' | ' + data.layer + ' | ' + data.reason;
} catch(err) { el.textContent=err.message; }
}
async function checkDepartmentMatching() {
var el=document.getElementById('dmResult'); el.textContent='正在核验…';
try {
var data=(await departmentMatchingRequest('check',{})).data;
el.textContent='核验完成:' + data.passed + ' / ' + data.total + ' 条归属一致。';
if (data.mismatches.length) el.innerHTML += '<div style="overflow:auto;max-height:240px"><table class="dm-table"><thead><tr><th>店铺</th><th>预期归属</th><th>实际归属</th><th>原因</th></tr></thead><tbody>' + data.mismatches.map(function(r){return '<tr><td>' + escHtml(r.shop) + '</td><td>' + escHtml(r.expected) + '</td><td>' + escHtml(r.actual || '未匹配') + '</td><td>' + escHtml(r.reason) + '</td></tr>';}).join('') + '</tbody></table></div>';
} catch(err) { el.textContent=err.message; }
}

View File

@ -200,6 +200,10 @@
<span class="nav-sub-icon">&#9679;</span>
<span>人员信息管理</span>
</a>
<a href="/department-matching" class="nav-sub-item {% if page == 'department_matching' %}active{% endif %}" data-page="department_matching">
<span class="nav-sub-icon">&#9679;</span>
<span>部门匹配管理</span>
</a>
</div>
</div>
{# 库存与出库(含子菜单): 所有人 #}
@ -467,6 +471,7 @@
</script>
<script src="/static/js/marked.min.js"></script>
<script src="/static/js/dompurify.min.js"></script>
<script src="/static/js/department_matching.js?v={{ app_js_version }}"></script>
<script src="/static/js/app.js?v={{ app_js_version }}"></script>
<script src="/static/js/knowledge-actions.js?v=1"></script>
<script>
@ -654,7 +659,7 @@
var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {});
if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) {
// 找第一个有权限的页面
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','organization','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','organization','department_matching','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
for (var pi = 0; pi < accessiblePages.length; pi++) {
var ap = accessiblePages[pi];
var apPerms = perms[ap] || {};

View File

@ -0,0 +1,107 @@
import unittest
from unittest.mock import patch
from flask import Flask
from services.department_matching import match_department, validate_record, check_samples
from routes.department_matching import department_matching_bp
def rule(kind, payload, row_id=1, priority=100, enabled=1):
return dict(kind=kind, payload=payload, id=row_id, priority=priority, enabled=enabled)
class MatchingTest(unittest.TestCase):
def test_specific_platform_precedes_general_keyword(self):
rows = [rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords=''), 1, 10),
rule('platform', dict(department='雪狼', shop_keywords='天猫国际', source_keywords=''), 2, 20)]
self.assertEqual(match_department('天猫国际', rows=rows)['department'], '雪狼')
def test_brand_overrides_platform_case_and_spaces(self):
rows = [rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords='')),
rule('brand', dict(keyword='DR JEVEITAL', mode='fixed', department='洁维塔'), 2)]
self.assertEqual(match_department('天猫-drjeveital旗舰店', rows=rows)['department'], '洁维塔')
def test_aromat_boundary(self):
rows = [rule('brand', dict(keyword='AROMAT', mode='fixed', department='雪狼'))]
self.assertEqual(match_department('AROMAT HERBS店', rows=rows)['department'], '雪狼')
self.assertEqual(match_department('AROMATHERBS店', rows=rows)['department'], '')
def test_conditional_brand_and_platform_fallback(self):
rows = [rule('brand', dict(keyword='MUTO', mode='split', department='灵动',
pdd_department='雪狼', overseas_department='雪狼')),
rule('brand', dict(keyword='UOUONE', mode='pdd_only', department='', pdd_department='春跃'), 2),
rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords=''), 3)]
for shop, expected in [('拼多多-MUTO', '雪狼'), ('京东海外-MUTO', '雪狼'),
('京东国际-MUTO', '雪狼'), ('抖音-MUTO', '灵动'),
('天猫-UOUONE', '战狼'), ('拼多多-UOUONE', '春跃')]:
with self.subTest(shop=shop):
self.assertEqual(match_department(shop, rows=rows)['department'], expected)
def test_source_only_for_empty_shop_and_exact(self):
rows = [rule('platform', dict(department='洁维塔', shop_keywords='阿里健康', source_keywords='jdmrt,京东洁维塔'))]
self.assertEqual(match_department(' ', 'JDMRT', rows)['department'], '洁维塔')
self.assertEqual(match_department('未知店铺', 'jdmrt', rows)['department'], '')
self.assertEqual(match_department('', 'jdmrt-extra', rows)['department'], '')
def test_disabled_rules_and_deterministic_priority(self):
rows = [rule('platform', dict(department='停用', shop_keywords='天猫国际', source_keywords=''), enabled=0),
rule('platform', dict(department='B', shop_keywords='天猫', source_keywords=''), 2, 20),
rule('platform', dict(department='A', shop_keywords='天猫', source_keywords=''), 3, 10)]
self.assertEqual(match_department('天猫国际', rows=rows)['department'], 'A')
def test_samples_report_mismatches_without_becoming_rules(self):
rows = [rule('shop', dict(shop='未知店铺', department='A'))]
self.assertEqual(match_department('未知店铺', rows=rows)['department'], '')
report = check_samples(rows)
self.assertEqual((report['total'], report['passed']), (1, 0))
self.assertEqual(report['mismatches'][0]['expected'], 'A')
def test_reject_invalid_conditions_and_priority(self):
for data in [dict(kind='unknown', payload={}),
dict(kind='platform', payload=dict(department='A')),
dict(kind='brand', payload=dict(keyword='A', mode='split', department='B')),
dict(kind='platform', payload=dict(department='A', shop_keywords='x'), priority=-1)]:
with self.subTest(data=data), self.assertRaises(ValueError):
validate_record(data)
class MatchingApiTest(unittest.TestCase):
def setUp(self):
app = Flask(__name__)
app.secret_key = 'testing'
app.register_blueprint(department_matching_bp)
self.client = app.test_client()
patcher = patch('routes.auth.query', return_value=[{'status': 1}])
patcher.start()
self.addCleanup(patcher.stop)
def login(self):
with self.client.session_transaction() as session:
session['user_id'] = 1
session['role'] = 'viewer'
def test_view_and_write_permissions_are_enforced(self):
self.login()
with patch('routes.department_matching.role_required', return_value=False), \
patch('routes.department_matching.operation_required', return_value=False), \
patch('routes.department_matching.query') as db:
for path in ('records', 'save', 'delete', 'test', 'check'):
response = self.client.get('/api/department-matching/records') if path == 'records' else self.client.post('/api/department-matching/' + path, json={})
self.assertEqual(response.status_code, 403)
db.assert_not_called()
def test_read_only_test_has_no_writes(self):
self.login()
with patch('routes.department_matching.role_required', return_value=True), \
patch('routes.department_matching.match_department', return_value={'department': '雪狼'}) as match, \
patch('routes.department_matching.query') as db, \
patch('routes.department_matching.add_operation_log') as log:
response = self.client.post('/api/department-matching/test', json={'shop': '天猫国际'})
self.assertEqual(response.get_json()['data']['department'], '雪狼')
match.assert_called_once_with('天猫国际', '')
db.assert_not_called()
log.assert_not_called()
if __name__ == '__main__':
unittest.main()