Compare commits
3 Commits
6e064064d5
...
2e903cff2c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e903cff2c | |||
| d647af6bc3 | |||
| 754c46bd59 |
@ -52,6 +52,16 @@ def get_current_user_permissions():
|
||||
return perms
|
||||
|
||||
|
||||
def _invalidate_specs_cache():
|
||||
"""规格连号缓存失效(新增/修改/删除基础信息时调用)"""
|
||||
try:
|
||||
from app.extensions import redis_client
|
||||
if redis_client:
|
||||
redis_client.delete('inventory:specs:grouped')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""根据用户权限过滤字段,无权限的字段值置为 None"""
|
||||
if 'material_list:*' in user_permissions:
|
||||
@ -299,6 +309,7 @@ def create():
|
||||
filtered_data[key] = value
|
||||
|
||||
MaterialBaseService.create_material(filtered_data)
|
||||
_invalidate_specs_cache()
|
||||
return jsonify({"code": 200, "msg": "新增成功"})
|
||||
except ValueError as e:
|
||||
# 捕获业务逻辑验证错误 (如名称为空)
|
||||
@ -359,6 +370,7 @@ def update(id):
|
||||
filtered_data[key] = value
|
||||
# 使用过滤后的数据调用服务
|
||||
MaterialBaseService.update_material(id, filtered_data)
|
||||
_invalidate_specs_cache()
|
||||
return jsonify({"code": 200, "msg": "修改成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@ -378,6 +390,7 @@ def update(id):
|
||||
def delete(id):
|
||||
try:
|
||||
material_name = MaterialBaseService.delete_material(id)
|
||||
_invalidate_specs_cache()
|
||||
return jsonify({"code": 200, "msg": "删除成功", "material_name": material_name})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
@ -34,6 +34,25 @@ def _user_has_purchase_perm():
|
||||
)
|
||||
|
||||
|
||||
def _filter_purchase_prices(item_dict):
|
||||
"""Fail-Closed: 无价格权限则剥离采购价格字段"""
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
role = claims.get('role', '')
|
||||
if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'):
|
||||
return
|
||||
perm_dict = AuthService.get_user_permissions(role, company_name=claims.get('company_name', ''))
|
||||
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
if 'inbound_purchase:unit_price' not in all_perms:
|
||||
item_dict.pop('unit_price', None)
|
||||
item_dict.pop('pre_tax_unit_price', None)
|
||||
item_dict.pop('post_tax_unit_price', None)
|
||||
if 'inbound_purchase:total_price' not in all_perms:
|
||||
item_dict.pop('total_price', None)
|
||||
if 'inbound_purchase:tax_rate' not in all_perms:
|
||||
item_dict.pop('tax_rate', None)
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 1. 采购申请列表
|
||||
# GET /api/v1/purchase
|
||||
@ -58,6 +77,10 @@ def get_purchase_list():
|
||||
status=status
|
||||
)
|
||||
|
||||
# ★ 字段级价格过滤
|
||||
for item in (result.get('items') or []):
|
||||
_filter_purchase_prices(item)
|
||||
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@ -128,6 +151,7 @@ def get_purchase_detail(purchase_id):
|
||||
if purchase['requester_id'] != user_id and not _user_has_purchase_perm():
|
||||
return jsonify({'code': 403, 'msg': '无权查看此申请'}), 403
|
||||
|
||||
_filter_purchase_prices(purchase)
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': purchase}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
@ -241,6 +265,8 @@ def get_approved_unstocked_requests():
|
||||
page=page, per_page=per_page, keyword=keyword
|
||||
)
|
||||
|
||||
# ★ 注意:不在此处过滤价格。此端点用于按单入库,
|
||||
# 价格数据需随响应传递到入库表单(前端通过 inbound_buy:unit_price 权限控制写入)
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
@ -928,59 +928,96 @@ class MaterialBaseService:
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
|
||||
# 支持二级分类的前缀集合(如 OPT1, OPT2, LICA1 等子系列分组)
|
||||
SUB_CATEGORY_PREFIXES = {'OPT', 'LICA', 'M', 'UAV', 'CF', 'GPS'}
|
||||
|
||||
@staticmethod
|
||||
def get_latest_specs():
|
||||
"""
|
||||
获取所有规格型号的分组统计,按规则聚合后返回
|
||||
- 前缀统一大写处理
|
||||
- 匹配模式:(前缀)(单数字二级分类位)(纯数字部分),如 OPT12046 -> OPT, 1, 2046
|
||||
- OPT 系列:使用 前缀+二级分类位 作为分组 Key,如 OPT1, OPT2
|
||||
- 其他前缀:直接使用前缀作为分组 Key
|
||||
- 返回每个分组的数量、最大号、完整规格名
|
||||
规格连号助手 — 智能分组统计(v2: 流式读取 + Redis缓存 + 宽松regex)
|
||||
|
||||
匹配模式: PREFIX[-_]?NUMBERS[SUFFIX], 如:
|
||||
OPT12046 → OPT, 1, 2046
|
||||
LICA-3000 → LICA, 3000, ''
|
||||
M3x12 → M, 3, 'x12'
|
||||
|
||||
分组规则: 前缀在 SUB_CATEGORY_PREFIXES 中 → 前缀+首位数字作为key
|
||||
其他 → 只用前缀作为key
|
||||
"""
|
||||
import re
|
||||
import json as json_module
|
||||
from collections import defaultdict
|
||||
|
||||
# 1. 查询所有不为空的规格型号
|
||||
specs = MaterialBase.query.filter(
|
||||
MaterialBase.spec_model.isnot(None),
|
||||
MaterialBase.spec_model != ''
|
||||
).all()
|
||||
CACHE_KEY = 'inventory:specs:grouped'
|
||||
CACHE_TTL = 3600
|
||||
|
||||
# 2. 按分组收集所有数字
|
||||
# ── Redis 缓存 ──
|
||||
try:
|
||||
from app.extensions import redis_client
|
||||
if redis_client:
|
||||
cached = redis_client.get(CACHE_KEY)
|
||||
if cached:
|
||||
return json_module.loads(cached)
|
||||
except Exception:
|
||||
pass # Redis 不可用时降级
|
||||
|
||||
# ── 流式查询(yield_per 分批 + limit 防 OOM) ──
|
||||
pattern = re.compile(r'^([A-Za-z]+)[-_]?(\d+)(.*)$')
|
||||
groups = defaultdict(list)
|
||||
|
||||
for material in specs:
|
||||
spec = material.spec_model
|
||||
rows = MaterialBase.query.with_entities(
|
||||
MaterialBase.id, MaterialBase.spec_model
|
||||
).filter(
|
||||
MaterialBase.spec_model.isnot(None),
|
||||
MaterialBase.spec_model != ''
|
||||
).limit(10000).all()
|
||||
|
||||
for row in rows:
|
||||
spec = row.spec_model
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
base_spec = spec.split('/')[0]
|
||||
match = re.match(r'^([A-Za-z]+)(\d)(\d+)$', base_spec)
|
||||
match = pattern.match(base_spec)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
prefix, sub_cat, num_str = match.groups()
|
||||
prefix = prefix.upper()
|
||||
num = int(num_str)
|
||||
prefix = match.group(1).upper()
|
||||
num_str = match.group(2)
|
||||
suffix = match.group(3)
|
||||
|
||||
if not num_str:
|
||||
continue
|
||||
num = int(num_str)
|
||||
sub_cat = num_str[0] # 首位数字作为子分类
|
||||
|
||||
# 分组 key
|
||||
if prefix in MaterialBaseService.SUB_CATEGORY_PREFIXES and sub_cat:
|
||||
key = f"{prefix}_{sub_cat}"
|
||||
else:
|
||||
key = prefix
|
||||
|
||||
# OPT 系列使用 前缀+单数字二级分类 作为 Key
|
||||
key = f"{prefix}{sub_cat}" if prefix == 'OPT' else prefix
|
||||
groups[key].append((num, spec))
|
||||
|
||||
# 3. 生成展示用的统计数据
|
||||
# ── 生成结果 ──
|
||||
result = []
|
||||
for key, items in groups.items():
|
||||
sorted_items = sorted(items, key=lambda x: x[0])
|
||||
max_num, max_spec = sorted_items[-1]
|
||||
items.sort(key=lambda x: x[0])
|
||||
max_num, max_spec = items[-1]
|
||||
result.append({
|
||||
'group': key,
|
||||
'count': len(sorted_items),
|
||||
'count': len(items),
|
||||
'latest': max_spec,
|
||||
'max_num': max_num
|
||||
})
|
||||
|
||||
# 4. 按数量降序,再按分组名升序排列
|
||||
result.sort(key=lambda x: (-x['count'], x['group']))
|
||||
|
||||
# ── 写入 Redis 缓存 ──
|
||||
try:
|
||||
from app.extensions import redis_client
|
||||
if redis_client:
|
||||
redis_client.setex(CACHE_KEY, CACHE_TTL, json_module.dumps(result))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
@ -638,19 +638,23 @@ class PermissionService:
|
||||
db.session.add(new_perm)
|
||||
|
||||
db.session.commit()
|
||||
# ★ 采购申请操作权限元素
|
||||
purchase_op = SysElement.query.filter_by(
|
||||
menu_code='inbound_purchase',
|
||||
code='inbound_purchase:operation'
|
||||
).first()
|
||||
if not purchase_op:
|
||||
db.session.add(SysElement(
|
||||
menu_code='inbound_purchase',
|
||||
name='可编辑',
|
||||
code='inbound_purchase:operation',
|
||||
element_type='operation'
|
||||
))
|
||||
print(f"✅ 采购申请操作权限元素已创建")
|
||||
# ★ 采购申请权限元素
|
||||
purchase_elements = [
|
||||
('inbound_purchase:operation', '可编辑', 'operation'),
|
||||
('inbound_purchase:unit_price', '采购单价', 'column'),
|
||||
('inbound_purchase:total_price', '采购总价', 'column'),
|
||||
('inbound_purchase:tax_rate', '税率', 'column'),
|
||||
]
|
||||
for code, name, etype in purchase_elements:
|
||||
existing = SysElement.query.filter_by(
|
||||
menu_code='inbound_purchase', code=code
|
||||
).first()
|
||||
if not existing:
|
||||
db.session.add(SysElement(
|
||||
menu_code='inbound_purchase', name=name,
|
||||
code=code, element_type=etype
|
||||
))
|
||||
print(f"✅ 采购申请元素已创建: {code}")
|
||||
|
||||
print(f"✅ 所有菜单初始化完成")
|
||||
return True
|
||||
|
||||
@ -1202,6 +1202,8 @@ const loadGroupItems = async (category: string) => {
|
||||
if (queryParams.type) params.type = queryParams.type;
|
||||
if (queryParams.isEnabled !== undefined) params.isEnabled = queryParams.isEnabled;
|
||||
if (queryParams.company && queryParams.company !== 'ALL') params.company = queryParams.company;
|
||||
if (queryParams.orderByColumn) params.orderByColumn = queryParams.orderByColumn;
|
||||
if (queryParams.isAsc) params.isAsc = queryParams.isAsc;
|
||||
|
||||
const res: any = await listMaterialBase(params);
|
||||
if (res?.code === 200 && res.data) {
|
||||
@ -1265,7 +1267,9 @@ const handleSortChange = ({ column, prop, order }: any) => {
|
||||
queryParams.orderByColumn = '';
|
||||
queryParams.isAsc = undefined;
|
||||
}
|
||||
getList();
|
||||
// ★ 排序时:清除已展开分组缓存 + 重新加载(带排序参数)
|
||||
activeCategories.value.forEach(cat => groupCache.value.delete(cat));
|
||||
activeCategories.value.forEach(cat => loadGroupItems(cat));
|
||||
};
|
||||
|
||||
const handleQuery = () => { getList(); };
|
||||
|
||||
@ -24,17 +24,17 @@
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="quantity" label="数量" width="80" align="center" />
|
||||
<el-table-column prop="purchase_date" label="采购日期" width="110" />
|
||||
<el-table-column label="含税单价" width="110" align="right">
|
||||
<el-table-column v-if="userStore.hasPermission('inbound_purchase:unit_price')" label="含税单价" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.unit_price ? '¥' + Number(row.unit_price).toFixed(2) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="含税总价" width="120" align="right">
|
||||
<el-table-column v-if="userStore.hasPermission('inbound_purchase:total_price')" label="含税总价" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.total_price ? '¥' + Number(row.total_price).toFixed(2) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tax_rate" label="税率" width="70" align="center">
|
||||
<el-table-column v-if="userStore.hasPermission('inbound_purchase:tax_rate')" prop="tax_rate" label="税率" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.tax_rate != null ? row.tax_rate + '%' : '-' }}
|
||||
</template>
|
||||
@ -200,8 +200,8 @@
|
||||
<el-descriptions-item label="规格型号">{{ detail.spec_model || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采购数量">{{ detail.quantity }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采购日期">{{ detail.purchase_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单价">{{ detail.unit_price || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总价">{{ detail.total_price || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="userStore.hasPermission('inbound_purchase:unit_price')" label="单价">{{ detail.unit_price || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="userStore.hasPermission('inbound_purchase:total_price')" label="总价">{{ detail.total_price || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人">{{ detail.requester_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审批人">{{ detail.approver_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审批时间">{{ detail.approved_at || '-' }}</el-descriptions-item>
|
||||
|
||||
Reference in New Issue
Block a user