feat: add brand filters to product tabs
This commit is contained in:
parent
9d9b9e4176
commit
1210109255
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
50
tests/test_product_brand_filter.py
Normal file
50
tests/test_product_brand_filter.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user