From 8ea8a054598640e0584a6664383da0cc0f87da51 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 29 Oct 2026 18:24:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8B=E5=8D=88=E7=9A=84=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 2 +- routes/__init__.py | 2 + routes/auth.py | 1 + routes/department_matching.py | 82 ++++++++++ scripts/import_department_matching.py | 29 ++++ services/audit.py | 2 + services/department_matching.py | 208 ++++++++++++++++++++++++++ static/js/app.js | 5 +- static/js/department_matching.js | 139 +++++++++++++++++ templates/base.html | 7 +- tests/test_department_matching.py | 107 +++++++++++++ 11 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 routes/department_matching.py create mode 100644 scripts/import_department_matching.py create mode 100644 services/department_matching.py create mode 100644 static/js/department_matching.js create mode 100644 tests/test_department_matching.py 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 '';}).join('') + '
店铺预期归属实际归属原因
' + escHtml(r.shop) + '' + escHtml(r.expected) + '' + escHtml(r.actual || '未匹配') + '' + escHtml(r.reason) + '
'; + } 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 @@ +