feat: add brand filters to product tabs

This commit is contained in:
Codex 2026-10-12 11:16:48 +08:00
parent 9d9b9e4176
commit 1210109255
4 changed files with 134 additions and 9 deletions

View File

@ -171,16 +171,29 @@ def brand_management_delete():
return jsonify({"code": 400, "msg": "品牌名称不能为空"})
product_rows = query(
"SELECT COUNT(*) AS cnt FROM products "
"WHERE deleted_at IS NULL AND TRIM(brand)=%s",
"SELECT COALESCE(NULLIF(TRIM(product_type), ''), '未分类') AS product_type, "
"COUNT(*) AS cnt FROM products "
"WHERE deleted_at IS NULL AND TRIM(brand)=%s "
"GROUP BY COALESCE(NULLIF(TRIM(product_type), ''), '未分类') "
"ORDER BY cnt DESC",
(brand,),
)
product_count = int(product_rows[0].get('cnt') or 0) if product_rows else 0
product_count = sum(int(row.get('cnt') or 0) for row in product_rows)
if product_count:
usage_counts = {
(row.get('product_type') or '未分类'): int(row.get('cnt') or 0)
for row in product_rows
}
usage_summary = '、'.join(
f"{product_type}{count}条"
for product_type, count in usage_counts.items()
)
return jsonify({
"code": 409,
"msg": f"品牌「{brand}」正在被{product_count}个有效产品使用,请先修改这些产品的品牌后再删除",
"msg": f"品牌「{brand}」正在被产品信息管理基础表中的{product_count}条未回收记录使用"
f"({usage_summary}),请先修改这些产品的品牌后再删除",
"product_count": product_count,
"usage_counts": usage_counts,
})
saved = query("SELECT brand, category FROM brand_information WHERE brand=%s LIMIT 1", (brand,))
@ -253,7 +266,7 @@ def products_list():
where.append("(s.supplier_name LIKE %s OR p.supplier_name_match LIKE %s)")
params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"])
if brand_filter:
where.append("p.brand=%s")
where.append("TRIM(p.brand)=%s")
params.append(brand_filter)
if category1_filter:
where.append("p.category1=%s")
@ -377,8 +390,21 @@ def products_list():
@products_bp.route('/api/products/filters')
@login_required
def products_filters():
brands = query("SELECT DISTINCT brand FROM products WHERE brand IS NOT NULL AND brand != '' ORDER BY brand")
categories = query("SELECT DISTINCT category1 FROM products WHERE category1 IS NOT NULL AND category1 != '' ORDER BY category1")
scope = request.args.get('scope', 'all')
if scope == 'active':
scope_clause = 'deleted_at IS NULL AND '
elif scope == 'recycle':
scope_clause = 'deleted_at IS NOT NULL AND '
else:
scope_clause = ''
brands = query(
f"SELECT DISTINCT TRIM(brand) AS brand FROM products WHERE {scope_clause}"
"brand IS NOT NULL AND TRIM(brand) != '' ORDER BY TRIM(brand)"
)
categories = query(
f"SELECT DISTINCT category1 FROM products WHERE {scope_clause}"
"category1 IS NOT NULL AND category1 != '' ORDER BY category1"
)
return jsonify({
"code": 0,
"data": {
@ -2136,6 +2162,7 @@ def products_recycle_bin():
prod_name = request.args.get('prod_name', '')
unified_search = request.args.get('search', '')
supplier_filter = request.args.get('supplier', '')
brand_filter = request.args.get('brand', '')
product_type = request.args.get('product_type', '')
where = ["p.deleted_at IS NOT NULL"]
@ -2153,6 +2180,9 @@ def products_recycle_bin():
if supplier_filter:
where.append("(p.supplier_name_match LIKE %s OR s.supplier_name LIKE %s)")
params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"])
if brand_filter:
where.append("TRIM(p.brand)=%s")
params.append(brand_filter)
if product_type:
where.append("p.product_type=%s")
params.append(product_type)

View File

@ -8163,6 +8163,9 @@ function loadProductsPage(container) {
<div class="filter-bar" style="flex-wrap:wrap;gap:8px">
<input type="text" id="prodCodeName" class="form-input" style="width:220px" placeholder="货品编号/货品名称" autocomplete="off" onkeydown="if(event.key==='Enter')searchProducts()">
<input type="text" id="prodSupplier" class="form-input" style="width:120px" placeholder="供应商" autocomplete="off" onkeydown="if(event.key==='Enter')searchProducts()">
<select id="prodBrandFilter" class="form-input" style="width:140px;padding:6px 8px">
<option value="">全部品牌</option>
</select>
<select id="prodTypeFilter" class="form-input" style="width:110px;padding:6px 8px">
<option value="">货品类型</option>
<option value="成品">成品</option>
@ -8248,13 +8251,14 @@ function loadProductsPage(container) {
renderProdColCheckboxes();
// 清除筛选框搜索记忆
['prodCodeName','prodSupplier','prodTypeFilter'].forEach(function(id) {
['prodCodeName','prodSupplier','prodBrandFilter','prodTypeFilter'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
// 加载
prodActiveTab = '成品';
loadProductBrandFilter('成品');
updateProdTypeFilterOptions('成品');
applyProdTabColumns('成品');
loadProducts(1);
@ -8295,10 +8299,11 @@ function switchProdTab(tab) {
var addButton = document.getElementById('prodAddButton');
if (addButton) addButton.style.display = tab === '回收站' ? 'none' : '';
// 切页清空筛选框,互不影响
['prodCodeName','prodSupplier','prodTypeFilter'].forEach(function(id) {
['prodCodeName','prodSupplier','prodBrandFilter','prodTypeFilter'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
loadProductBrandFilter(tab);
// 更新货品类型下拉选项
updateProdTypeFilterOptions(tab);
applyProdTabColumns(tab);
@ -8307,6 +8312,21 @@ function switchProdTab(tab) {
loadProducts(1);
}
function loadProductBrandFilter(tab) {
var sel = document.getElementById('prodBrandFilter');
if (!sel) return;
var scope = tab === '回收站' ? 'recycle' : 'active';
fetch('/api/products/filters?scope=' + scope)
.then(function(r) { return r.json(); })
.then(function(res) {
if (!document.body.contains(sel) || !res || res.code !== 0) return;
var brands = (res.data && res.data.brands) || [];
sel.innerHTML = '<option value="">全部品牌</option>' + brands.map(function(brand) {
return '<option value="' + escHtml(brand) + '">' + escHtml(brand) + '</option>';
}).join('');
});
}
function updateProdTypeFilterOptions(tab) {
var sel = document.getElementById('prodTypeFilter');
if (!sel) return;
@ -8347,6 +8367,7 @@ function loadProducts(page) {
prodCurrentPage = page;
var codeName = (document.getElementById('prodCodeName')?.value || '').trim();
var supplier = (document.getElementById('prodSupplier')?.value || '').trim();
var brandFilter = document.getElementById('prodBrandFilter')?.value || '';
var typeFilter = document.getElementById('prodTypeFilter')?.value || '';
if (prodActiveTab === '回收站') {
@ -8356,6 +8377,7 @@ function loadProducts(page) {
var url = '/api/products/list?page=' + page + '&size=' + PROD_PAGE_SIZE;
if (codeName) url += '&search=' + encodeURIComponent(codeName);
if (supplier) url += '&supplier=' + encodeURIComponent(supplier);
if (brandFilter) url += '&brand=' + encodeURIComponent(brandFilter);
if (prodActiveTab === '成品') {
if (typeFilter) {
url += '&product_type=' + encodeURIComponent(typeFilter);
@ -8448,6 +8470,8 @@ window.searchProducts = function() { loadProducts(1); };
window.resetProductFilters = function() {
document.getElementById('prodCodeName').value = '';
document.getElementById('prodSupplier').value = '';
var bf = document.getElementById('prodBrandFilter');
if (bf) bf.value = '';
var tf = document.getElementById('prodTypeFilter');
if (tf) tf.value = '';
loadProducts(1);
@ -8456,10 +8480,12 @@ window.resetProductFilters = function() {
function loadRecycleProducts(page) {
var codeName = (document.getElementById('prodCodeName')?.value || '').trim();
var supplier = (document.getElementById('prodSupplier')?.value || '').trim();
var brandFilter = document.getElementById('prodBrandFilter')?.value || '';
var typeFilter = document.getElementById('prodTypeFilter')?.value || '';
var url = '/api/products/recycle_bin?page=' + page + '&size=' + PROD_PAGE_SIZE;
if (codeName) url += '&search=' + encodeURIComponent(codeName);
if (supplier) url += '&supplier=' + encodeURIComponent(supplier);
if (brandFilter) url += '&brand=' + encodeURIComponent(brandFilter);
if (typeFilter) url += '&product_type=' + encodeURIComponent(typeFilter);
fetch(url)

View File

@ -43,9 +43,28 @@ class BrandManagementDeleteTest(unittest.TestCase):
body = response.get_json()
self.assertEqual(body['code'], 409)
self.assertEqual(body['product_count'], 3)
self.assertEqual(body['usage_counts'], {'未分类': 3})
self.assertIn('产品信息管理基础表', body['msg'])
self.assertEqual(query.call_count, 1)
operation_log.assert_not_called()
def test_delete_usage_message_lists_product_type_breakdown(self):
with mock.patch.object(products, 'operation_required', return_value=True), \
mock.patch.object(products, 'query', return_value=[
{'product_type': '包材', 'cnt': 5990},
{'product_type': '其他', 'cnt': 66},
{'product_type': '成品', 'cnt': 3},
]):
response = self.client.post(
'/api/brand-management/delete', json={'brand': '无'}
)
body = response.get_json()
self.assertEqual(body['code'], 409)
self.assertEqual(body['product_count'], 6059)
self.assertEqual(body['usage_counts']['包材'], 5990)
self.assertIn('包材5990条', body['msg'])
def test_reject_delete_without_edit_permission(self):
with mock.patch.object(products, 'operation_required', return_value=False), \
mock.patch.object(products, 'query') as query:

View File

@ -0,0 +1,50 @@
"""产品信息管理三个 Tab 品牌筛选回归测试。"""
import unittest
from pathlib import Path
from unittest import mock
import test_wsgi # noqa: F401
from app import app
from routes import products
APP_JS = (Path(__file__).resolve().parents[1] / 'static/js/app.js').read_text(encoding='utf-8')
class ProductBrandFilterStaticTest(unittest.TestCase):
def test_all_tabs_share_brand_filter(self):
self.assertIn('id="prodBrandFilter"', APP_JS)
self.assertIn("loadProductBrandFilter('成品')", APP_JS)
self.assertIn('loadProductBrandFilter(tab)', APP_JS)
self.assertGreaterEqual(APP_JS.count("url += '&brand='"), 2)
class ProductBrandFilterApiTest(unittest.TestCase):
def setUp(self):
app.config.update(TESTING=True)
self.client = app.test_client()
with self.client.session_transaction() as session:
session.update(user_id=1, username='brand-filter-test', role='super_admin')
def test_recycle_bin_applies_brand_filter(self):
with mock.patch.object(products, 'query', side_effect=[[{'cnt': 0}], []]) as query:
response = self.client.get('/api/products/recycle_bin?brand=%E6%97%A0')
self.assertEqual(response.get_json()['code'], 0)
self.assertIn('TRIM(p.brand)=%s', query.call_args_list[0].args[0])
self.assertEqual(query.call_args_list[0].args[1], ['无'])
def test_filter_options_can_be_scoped_to_recycle_bin(self):
with mock.patch.object(products, 'query', side_effect=[
[{'brand': '回收品牌'}],
[{'category1': '分类'}],
]) as query:
response = self.client.get('/api/products/filters?scope=recycle')
self.assertEqual(response.get_json()['data']['brands'], ['回收品牌'])
self.assertIn('deleted_at IS NOT NULL', query.call_args_list[0].args[0])
if __name__ == '__main__':
unittest.main()