diff --git a/app.py b/app.py
index 7f082a4..bc645e7 100644
--- a/app.py
+++ b/app.py
@@ -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',
diff --git a/routes/__init__.py b/routes/__init__.py
index 97f7b31..4d539d8 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -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',
diff --git a/routes/auth.py b/routes/auth.py
index 1959ec4..752a6f0 100644
--- a/routes/auth.py
+++ b/routes/auth.py
@@ -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':'日志治理'}],
diff --git a/routes/department_matching.py b/routes/department_matching.py
new file mode 100644
index 0000000..95b01eb
--- /dev/null
+++ b/routes/department_matching.py
@@ -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())
diff --git a/scripts/import_department_matching.py b/scripts/import_department_matching.py
new file mode 100644
index 0000000..de89a64
--- /dev/null
+++ b/scripts/import_department_matching.py
@@ -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()
diff --git a/services/audit.py b/services/audit.py
index 834f90c..6dc1b06 100644
--- a/services/audit.py
+++ b/services/audit.py
@@ -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', # 库存导出(生成文件,不修改业务数据)
diff --git a/services/department_matching.py b/services/department_matching.py
new file mode 100644
index 0000000..403e55e
--- /dev/null
+++ b/services/department_matching.py
@@ -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}
diff --git a/static/js/app.js b/static/js/app.js
index 5ed475c..fb617ff 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -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},
diff --git a/static/js/department_matching.js b/static/js/department_matching.js
new file mode 100644
index 0000000..0256112
--- /dev/null
+++ b/static/js/department_matching.js
@@ -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 = `
+
+
+
+
+
+
非品牌清单用于解释排除项;归属通过平台规则判定。平台层先比较关键词长度,长度相同时优先级数字越小越优先。
+
正在加载…
`;
+ 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 '';
+ }).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 = '序号 | ' + fields.map(function(f){return '' + escHtml(f[1].replace('(必填)','')) + ' | ';}).join('') + (showPriority ? '优先级 | ' : '') + (rule ? '状态 | ' : '') + (canEdit || canDelete ? '操作 | ' : '');
+ var body = rows.slice((state.page - 1) * 20, state.page * 20).map(function(row, i) {
+ return '| ' + ((state.page - 1) * 20 + i + 1) + ' | ' + fields.map(function(f) {
+ var value = row.payload[f[0]] || '';
+ if (f[0] === 'mode') value = departmentMatchingModes[value] || value;
+ return '' + escHtml(String(value)) + ' | ';
+ }).join('') + (showPriority ? '' + row.priority + ' | ' : '') + (rule ? '' + (row.enabled ? '启用' : '停用') + ' | ' : '') + (canEdit || canDelete ? '' +
+ (canEdit ? ' ' : '') +
+ (canDelete ? '' : '') + ' | ' : '') + '
';
+ }).join('');
+ document.getElementById('dmTable').innerHTML = '' + heads + '
' + (body || '| 暂无记录 |
') + '
';
+ document.getElementById('dmPager').innerHTML = '共 ' + rows.length + ' 条,每页20条' + state.page + ' / ' + pages + '';
+}
+
+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 = '';
+ else control = '';
+ return '' + control + '
';
+ }).join('');
+ var rule = row.kind === 'brand' || row.kind === 'platform';
+ modal.innerHTML = '';
+ 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 += '| 店铺 | 预期归属 | 实际归属 | 原因 |
' + data.mismatches.map(function(r){return '| ' + escHtml(r.shop) + ' | ' + escHtml(r.expected) + ' | ' + escHtml(r.actual || '未匹配') + ' | ' + escHtml(r.reason) + ' |
';}).join('') + '
';
+ } catch(err) { el.textContent=err.message; }
+}
diff --git a/templates/base.html b/templates/base.html
index aa65c0b..c3a5c23 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -200,6 +200,10 @@
●
人员信息管理
+
+ ●
+ 部门匹配管理
+
{# 库存与出库(含子菜单): 所有人 #}
@@ -467,6 +471,7 @@
+