88 Commits

Author SHA1 Message Date
dxc
16350842f8 fix: correct cost calculation for semi and product exports
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 12:22:45 +08:00
dxc
d7dff943fc feat: use highest historical unit price for material bases in export 2026-03-02 12:22:30 +08:00
dxc
2f140e112f fix: remove total_price from product inbound service
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 12:12:57 +08:00
dxc
8264867b1c fix: add total_price field to product inbound creation and update calculation
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 12:10:41 +08:00
dxc
d993e6796e refactor: remove total_price from product inbound service 2026-03-02 12:09:24 +08:00
dxc
4e05734865 fix: split cost fields into multiple rows in product.vue
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 12:03:06 +08:00
dxc
7f19867139 fix: adjust product service to use manual_cost instead of unit_total_cost
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 11:57:14 +08:00
dxc
bcd39729f8 fix: adjust BOM cost calculation SQL and refactor for consistency
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 11:52:24 +08:00
dxc
9cfbdc7d13 feat: refactor cost handling and add BOM cost calculation 2026-03-02 11:51:24 +08:00
dxc
d3510b0261 fix: correct BOM cost calculation by using raw SQL and manual_cost
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 11:47:44 +08:00
dxc
7b0082c6e0 feat: add BOM cost calculation for product inbound service 2026-03-02 11:44:50 +08:00
dxc
b08196c479 refactor: replace manual_cost with unit_total_cost and total_price
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 11:39:49 +08:00
dxc
68ea351c99 refactor: replace manual_cost with unit_total_cost and total_price
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 11:35:55 +08:00
dxc
f001be9eef feat: replace manual cost with unit total cost in inbound forms
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 10:28:43 +08:00
dxc
545cd86632 refactor: simplify cost calculation to 3 fields, drop manual_cost
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 10:24:51 +08:00
dxc
b688480892 refactor: use highest unit price per material base in export
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-03-02 09:55:49 +08:00
dxc
646804bb98 修改半成品的分为单价和总价格 2026-03-02 09:22:41 +08:00
dxc
3daf7e4500 成品下拉框修改完成 2026-02-28 17:37:34 +08:00
dxc
e61c179d77 修改半成品和成品新增时候搜索下拉框显示问题,新增负责人和生产人历史记录功能 2026-02-28 17:27:57 +08:00
dxc
f7cfb5a346 修改半成品和成品新增时候搜索下拉框显示问题,新增负责人和生产人历史记录功能 2026-02-28 17:08:35 +08:00
dxc
29fd397e4f fix: use path converter for BOM routes
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 15:44:38 +08:00
dxc
54d83803c4 fix: URL-encode BOM numbers containing slashes
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 15:40:59 +08:00
dxc
05fbb4e3b3 fix: sanitize bomNo to avoid duplicate path in detail API
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 15:38:02 +08:00
dxc
fb56359f41 fix: use ilike and trim for category, company and type filters
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 14:05:17 +08:00
dxc
00ebffb9fd 修改盘库时候数量增加减少的按钮大小 2026-02-28 12:05:21 +08:00
dxc
4b29912f6f feat: add borrowed quantity column and update stocktake export formulas
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 11:55:19 +08:00
dxc
cc33108e88 feat: add TransBorrow.get_borrowed_quantity method
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 11:43:10 +08:00
dxc
d78ef22251 fix: prevent price data leak in inventory export
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 11:32:21 +08:00
dxc
c3e2494b3e fix: correct default sorting and export desensitization logic
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 11:23:00 +08:00
dxc
fed85e51c5 feat: add sorting and export desensitization to material list
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-28 11:09:02 +08:00
dxc
d2082c712b 2.0录入测试版 2026-02-28 10:49:09 +08:00
dxc
b85f28fc72 修改采购件页面金额显示,修改权限管理页面非字段级内容可见与可编辑联动 2026-02-28 09:23:07 +08:00
dxc
8f6d0cd40b 修改采购件页面金额显示,修改权限管理页面非字段级内容可见与可编辑联动 2026-02-28 09:10:51 +08:00
dxc
281a41c549 feat: add company, category and material_type filters to product list
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 17:23:33 +08:00
dxc
dda54e829b feat: add category and type filters to product search
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 17:18:25 +08:00
dxc
5beb373677 fix: standardize operator role to uppercase for permission checks
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 17:11:29 +08:00
dxc
c1e4acc1d8 fix: standardize role case handling in permission logic
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 17:07:45 +08:00
dxc
a0993767fe fix: make SUPER_ADMIN role checks case-insensitive across app
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 17:04:22 +08:00
dxc
ad8bb5a75d feat: adapt semi and product inbound views for tablet and hide barcode input
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 16:53:28 +08:00
dxc
c414efc7a4 权限管理完成,在进行采购件税前税后单价新增字段 2026-02-27 16:45:17 +08:00
dxc
09a2af0b55 refactor: rename unit_price to pre_tax_unit_price in outbound service
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 16:43:30 +08:00
dxc
89620b2445 fix: case-insensitive super admin role check and wildcard permission
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 16:34:23 +08:00
dxc
a1df62238e fix: correct post-tax unit price calculation in buy inbound service
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 16:28:22 +08:00
dxc
3a056335bb refactor: adapt price fields to StockBuy model changes in export_excel
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 16:22:51 +08:00
dxc
fbff519ac9 fix: remove duplicate updatePrices function
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 16:06:18 +08:00
dxc
657c916703 feat: add post-tax unit price, company filter, and frontend price linkage
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 15:58:55 +08:00
dxc
3c1c822f88 feat: add pre/post-tax price linkage, hide barcode, and tablet adapt
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 15:44:10 +08:00
dxc
4324e5a688 feat: add field-level data protection for BOM and user management
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 15:16:11 +08:00
dxc
1fe00a8ba3 feat: Add field permission checks to outbound and transaction APIs
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 15:11:10 +08:00
dxc
afcf90a859 feat: enforce field-level permissions for buy and service modules
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 15:03:44 +08:00
dxc
5bc3dab31c feat: add field-level permission control for inbound modules
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:52:12 +08:00
dxc
079987e7f3 feat: enforce field-level permissions for material creation and update
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:41:27 +08:00
dxc
00c45c72fb inventory-web/src/views/system/UserCreate.vue
```vue
<<<<<<< SEARCH
          <el-button type="primary" @click="handleCreate">
            + 新增员工
          </el-button>
=======
          <el-button v-if="userStore.hasPermission('system_user:operation')" type="primary" @click="handleCreate">
            + 新增员工
          </el-button>
>>>>>>> REPLACE
```

Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:34:48 +08:00
dxc
6fa5233ea6 feat: implement RBAC and field masking for system_user module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:28:48 +08:00
dxc
3f83e8742b fix: remove duplicate error messages in BOM manage page
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:20:51 +08:00
dxc
348e4dd024 feat: add RBAC read-write separation and field masking for bom_manage
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:13:02 +08:00
dxc
42b0cddd3e feat: add column permission checks to transaction records table
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:07:48 +08:00
dxc
a2b1a62132 feat: add RBAC and field masking for borrow/return/records pages
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 14:05:52 +08:00
dxc
5065410662 feat: add RBAC control for outbound list module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:57:59 +08:00
dxc
3714dd180b feat: apply RBAC read/write separation to outbound_create module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:54:06 +08:00
dxc
af41eb1803 feat: add RBAC controls for outbound selection module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:45:49 +08:00
dxc
f79fb53b17 inventory-web/src/views/stock/stocktake/index.vue
```vue
<<<<<<< SEARCH
          <el-button type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
            开始新盘点
          </el-button>
=======
          <el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
            开始新盘点
          </el-button>
>>>>>>> REPLACE
```

inventory-web/src/views/stock/stocktake/index.vue
```vue
<<<<<<< SEARCH
          <el-button
              v-if="serverDraftCount > 0"
              type="warning"
              plain
              size="large"
              class="action-btn-full"
              @click="resumeSession"
              :loading="btnLoading"
          >
            继续上次盘点 <span class="sub-text">({{ serverDraftCount }}项)</span>
          </el-button>
=======
          <el-button
              v-if="serverDraftCount > 0 && userStore.hasPermission('inventory_stocktake:operation')"
              type="warning"
              plain
              size="large"
              class="action-btn-full"
              @click="resumeSession"
              :loading="btnLoading"
          >
            继续上次盘点 <span class="sub-text">({{ serverDraftCount }}项)</span>
          </el-button>
>>>>>>> REPLACE
```

Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:40:55 +08:00
dxc
38f0bbe41d feat: add RBAC for inventory stocktake module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:36:10 +08:00
dxc
1ad477eda8 feat: add permission management to inbound service module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:12:45 +08:00
dxc
1d2e8feced feat: apply RBAC permission control to product module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 13:03:27 +08:00
dxc
246fb45cde fix: correct try block syntax
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 12:56:54 +08:00
dxc
6e914f1e96 feat: add RBAC permission control for semi inbound module
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 12:08:57 +08:00
dxc
b5b1efdc4e fix: remove duplicate allColumns declaration
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 11:56:15 +08:00
dxc
56bb6a1c84 chore: add user store import to buy inbound view
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 11:51:28 +08:00
dxc
379bc5786f feat: implement RBAC for inbound buy module with field-level permissions
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 11:48:33 +08:00
dxc
a96597da33 基础信息页面权限管理成功,后端返回值隐藏未开放权限内容 2026-02-27 11:10:22 +08:00
dxc
4c1c61065e fix: exclude operation columns from field permission dropdown
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 11:08:00 +08:00
dxc
25487dbede fix: operation permission detection for codes ending with :operation
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 11:00:44 +08:00
dxc
a547d6b164 fix: restore strict column permission control
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:57:30 +08:00
dxc
661ce4e5a0 fix: disable column hiding by permissions in material list view
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:54:18 +08:00
dxc
d6d9621bf3 fix: resolve global permission code collision with material_list prefix
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:43:32 +08:00
dxc
f178b9cd00 fix: correct permission codes in inbound base API
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:36:28 +08:00
dxc
11fafde5e3 fix: remove temporary role whitelist and add permission denial logging
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:29:15 +08:00
dxc
1f9a363545 chore: add debug logs and temp whitelist to permission decorator
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:27:44 +08:00
dxc
b3e1ac6245 feat: implement permission checking and field-level data masking
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:20:09 +08:00
dxc
73ee163352 feat: add MaterialBase permission control with field-level filtering
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 10:16:43 +08:00
dxc
c86e67b793 feat: refresh user permissions on page reload
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-26 16:21:35 +08:00
dxc
57c2c532ca fix: skip column permission checks for super admin and IRIS
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-26 16:14:01 +08:00
dxc
dad7ffdc66 fix: tie column display to user permissions in material list
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-26 16:07:54 +08:00
dxc
b798c42abf feat: add permission control for material list page
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-26 15:58:23 +08:00
dxc
8698b2582c refactor: rebuild permission tree and improve assignment with error handling 2026-02-26 15:57:59 +08:00
dxc
220f50dba6 超级权限管理员权限重复提交报错 2026-02-25 16:54:52 +08:00
dxc
7431f1f41e 权限管理,没有页面修改之前版本 2026-02-25 16:10:12 +08:00
48 changed files with 4419 additions and 802 deletions

View File

@ -80,7 +80,6 @@ def create_app():
# -----------------------------------------------------
# 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
# ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★
# -----------------------------------------------------
try:
from app.api.v1.transactions import trans_bp
@ -90,8 +89,7 @@ def create_app():
app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy')
print("✅ Transactions 模块注册成功")
except ImportError as e:
# 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题
print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {e}")
print(f"⚠️ 提示: Transaction 模块导入失败: {e}")
# -----------------------------------------------------
# 2.5 注册出库模块 (Outbound)
@ -119,6 +117,19 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: BOM 模块导入失败: {e}")
# -----------------------------------------------------
# 2.7 注册权限管理模块 (Permission) - [新增]
# -----------------------------------------------------
try:
from app.api.v1.permission import permission_bp
# 标准: /api/v1/permissions/tree
app.register_blueprint(permission_bp, url_prefix='/api/v1/permissions')
# 兼容: /api/permissions/tree
app.register_blueprint(permission_bp, url_prefix='/api/permissions', name='permission_legacy')
print("✅ Permission 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Permission 模块导入失败 (请检查 app/api/v1/permission.py 是否存在): {e}")
# =========================================================
# 3. 预加载数据模型
# =========================================================
@ -133,8 +144,8 @@ def create_app():
# 出库模型
from app.models.outbound import TransOutbound
# 系统与业务模型
from app.models.system import SysUser, SysLog
# 系统与业务模型 (SysRolePermission 等在 models.system 中)
from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission
# 确保借还模型被加载
from app.models.transaction import TransBorrow, TransRepair, TransScrap
@ -146,4 +157,4 @@ def create_app():
except Exception as e:
print(f"⚠️ 模型预加载发生未知错误: {e}")
return app
return app

View File

@ -2,10 +2,56 @@
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt
from app.services.auth_service import AuthService
from app.utils.decorators import permission_required
auth_bp = Blueprint('auth', __name__)
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['system_user:*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'id': 'system_user:id',
'username': 'system_user:username',
'account_id': 'system_user:account_id',
'email': 'system_user:email',
'department': 'system_user:department',
'role': 'system_user:role',
'status': 'system_user:status',
'created_at': 'system_user:created_at',
}
# 如果用户是超级管理员且有 'system_user:*',则不过滤
if 'system_user:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
@auth_bp.route('/login', methods=['POST'])
def login():
try:
@ -34,9 +80,36 @@ def login():
@auth_bp.route('/user/create', methods=['POST'])
@jwt_required()
@permission_required('system_user:operation')
def create_user():
try:
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'system_user:*' not in user_permissions:
# 字段名到权限码的映射
field_to_perm = {
'cn_name': 'system_user:username',
'username': 'system_user:username',
'password': 'system_user:password',
'department': 'system_user:department',
'role': 'system_user:role',
'email': 'system_user:email',
}
# 对于 password 字段,如果没有对应权限但用户有操作权限,可以保留(由装饰器保证)
# 但如果连操作权限都没有,则不会进入此接口。
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
# 密码字段特殊处理:如果没有 password 权限但用户有操作权限,仍允许(不删除)
if field == 'password':
# 检查用户是否有操作权限,如果有则保留
if 'system_user:operation' not in user_permissions:
data.pop(field, None)
continue
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
claims = get_jwt()
operator_role = claims.get('role')
@ -51,9 +124,34 @@ def create_user():
# [新增] 更新用户
@auth_bp.route('/user/<int:user_id>', methods=['PUT'])
@jwt_required()
@permission_required('system_user:operation')
def update_user(user_id):
try:
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'system_user:*' not in user_permissions:
# 字段名到权限码的映射
field_to_perm = {
'cn_name': 'system_user:username',
'username': 'system_user:username',
'password': 'system_user:password',
'department': 'system_user:department',
'role': 'system_user:role',
'email': 'system_user:email',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
# 密码字段特殊处理:如果没有 password 权限但用户有操作权限,仍允许(不删除)
if field == 'password':
# 检查用户是否有操作权限,如果有则保留
if 'system_user:operation' not in user_permissions:
data.pop(field, None)
continue
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
claims = get_jwt()
operator_role = claims.get('role')
@ -67,10 +165,14 @@ def update_user(user_id):
@auth_bp.route('/users', methods=['GET'])
@jwt_required()
@permission_required('system_user')
def get_users():
try:
users = AuthService.get_all_users()
return jsonify({'msg': '获取成功', 'data': users}), 200
# 字段级脱敏
user_permissions = get_current_user_permissions()
filtered_users = [filter_item_by_permissions(user, user_permissions) for user in users]
return jsonify({'msg': '获取成功', 'data': filtered_users}), 200
except Exception as e:
current_app.logger.error(f"Get Users Failed: {str(e)}")
return jsonify({'msg': '获取用户列表失败'}), 500
@ -78,6 +180,7 @@ def get_users():
@auth_bp.route('/user/<int:user_id>', methods=['DELETE'])
@jwt_required()
@permission_required('system_user:operation')
def delete_user(user_id):
try:
claims = get_jwt()
@ -87,4 +190,21 @@ def delete_user(user_id):
return jsonify({'msg': '删除成功'}), 200
except Exception as e:
current_app.logger.error(f"Delete User Failed: {str(e)}")
return jsonify({'msg': str(e)}), 400
return jsonify({'msg': str(e)}), 400
@auth_bp.route('/my-permissions', methods=['GET'])
@jwt_required()
def get_my_permissions():
"""获取当前登录用户的权限列表"""
try:
claims = get_jwt()
role = claims.get('role')
# 调用 Service 获取权限
permissions = AuthService.get_user_permissions(role)
return jsonify({'msg': '获取成功', 'data': permissions}), 200
except Exception as e:
current_app.logger.error(f"Get Permissions Failed: {str(e)}")
return jsonify({'msg': '获取权限失败'}), 500

View File

@ -3,15 +3,61 @@ from app.services.bom_service import BomService
from app.models.base import MaterialBase
from app.models.bom import BomTable
from app.extensions import db
from flask_jwt_extended import jwt_required
from flask_jwt_extended import jwt_required, get_jwt
from app.utils.decorators import permission_required
from app.services.auth_service import AuthService
bom_bp = Blueprint('bom', __name__)
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['bom_manage:*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'bom_no': 'bom_manage:bom_no',
'parent_name': 'bom_manage:parent_name',
'parent_spec': 'bom_manage:parent_spec',
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'child_count': 'bom_manage:child_count',
}
# 如果用户是超级管理员且有 'bom_manage:*',则不过滤
if 'bom_manage:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
# ==================== 新版 BOM 接口(基于 bom_no ====================
@bom_bp.route('/list', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_list():
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
try:
@ -20,6 +66,10 @@ def get_bom_list():
active_only = request.args.get('active_only', 'false').lower() == 'true'
data = BomService.get_bom_list(keyword=keyword, active_only=active_only)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if isinstance(data, list):
data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({
'code': 200,
'msg': 'success',
@ -30,8 +80,9 @@ def get_bom_list():
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/detail/<bom_no>', methods=['GET'])
@bom_bp.route('/detail/<path:bom_no>', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_detail(bom_no):
"""
根据 BOM 编号获取配方详情
@ -42,6 +93,9 @@ def get_bom_detail(bom_no):
data = BomService.get_bom_detail(bom_no, version=version)
if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({
'code': 200,
'msg': 'success',
@ -54,10 +108,41 @@ def get_bom_detail(bom_no):
@bom_bp.route('/save', methods=['POST'])
@jwt_required()
@permission_required('bom_manage:operation')
def save_bom():
"""保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)"""
try:
req_data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'bom_manage:*' not in user_permissions:
# 字段名到权限码的映射
field_to_perm = {
'parent_id': 'bom_manage:parent_id',
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'bom_no': 'bom_manage:bom_no',
}
# 清洗顶级字段
for field in list(req_data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
req_data.pop(field, None)
# 清洗 children 中的字段
if 'children' in req_data and isinstance(req_data['children'], list):
for child in req_data['children']:
# 子件字段映射
child_field_to_perm = {
'child_id': 'bom_manage:child_id',
'dosage': 'bom_manage:dosage',
'remark': 'bom_manage:remark',
}
for field in list(child.keys()):
perm_code = child_field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
child.pop(field, None)
# 必需字段校验
if 'parent_id' not in req_data or 'children' not in req_data:
return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400
@ -79,14 +164,18 @@ def save_bom():
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/stock/<bom_no>', methods=['GET'])
@bom_bp.route('/stock/<path:bom_no>', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_with_stock_by_no(bom_no):
"""根据 BOM 编号获取配方详情及库存信息"""
try:
data = BomService.get_bom_with_stock_by_bom_no(bom_no)
if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({
'code': 200,
'msg': 'success',
@ -99,8 +188,9 @@ def get_bom_with_stock_by_no(bom_no):
# ==================== 删除BOM接口 ====================
@bom_bp.route('/<bom_no>', methods=['DELETE'])
@bom_bp.route('/<path:bom_no>', methods=['DELETE'])
@jwt_required()
@permission_required('bom_manage:operation')
def delete_bom(bom_no):
"""
根据 BOM 编号删除
@ -133,9 +223,13 @@ def delete_bom(bom_no):
@bom_bp.route('/<int:parent_id>', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom(parent_id):
try:
data = BomService.get_bom_with_stock(parent_id)
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({
'code': 200,
'msg': 'success',
@ -148,9 +242,40 @@ def get_bom(parent_id):
@bom_bp.route('', methods=['POST'])
@jwt_required()
@permission_required('bom_manage:operation')
def save_bom_legacy():
try:
req_data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'bom_manage:*' not in user_permissions:
# 字段名到权限码的映射
field_to_perm = {
'parent_id': 'bom_manage:parent_id',
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'bom_no': 'bom_manage:bom_no',
}
# 清洗顶级字段
for field in list(req_data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
req_data.pop(field, None)
# 清洗 children 中的字段
if 'children' in req_data and isinstance(req_data['children'], list):
for child in req_data['children']:
# 子件字段映射
child_field_to_perm = {
'child_id': 'bom_manage:child_id',
'dosage': 'bom_manage:dosage',
'remark': 'bom_manage:remark',
}
for field in list(child.keys()):
perm_code = child_field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
child.pop(field, None)
parent_id = req_data.get('parent_id')
child_list = req_data.get('children', [])
if not parent_id or not isinstance(child_list, list):
@ -169,11 +294,14 @@ def save_bom_legacy():
@bom_bp.route('/base/list', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_material_base_list():
"""获取所有基础物料列表,用于前端下拉框"""
try:
materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all()
data = [item.to_dict() for item in materials]
# 字段级脱敏 (如果需要,但此接口通常用于下拉选择,可能不需要脱敏)
# 保持原样
return jsonify({
'code': 200,
'msg': 'success',
@ -186,12 +314,16 @@ def get_material_base_list():
@bom_bp.route('/parents', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_parents():
"""获取所有已定义BOM的父件物料列表兼容旧版"""
try:
subq = db.session.query(BomTable.parent_id).distinct().subquery()
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
data = [item.to_dict() for item in parents]
# 字段级脱敏 (如果需要)
user_permissions = get_current_user_permissions()
data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({
'code': 200,
'msg': 'success',
@ -199,4 +331,4 @@ def get_bom_parents():
})
except Exception as e:
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500

View File

@ -1,22 +1,95 @@
# 文件路径: app/api/v1/inbound/base.py
from flask import Blueprint, request, jsonify, send_file
from flask import Blueprint, request, jsonify, send_file, g
from app.services.inbound.base_service import MaterialBaseService
from app.utils.decorators import login_required, permission_required
import traceback
import datetime
inbound_base_bp = Blueprint('stock_base', __name__)
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
# 返回通配符权限(供列表脱敏使用)以及所有具体权限(供导出脱敏使用)
return [
'material_list:*',
'material_list:id',
'material_list:companyName',
'material_list:name',
'material_list:commonName',
'material_list:category',
'material_list:type',
'material_list:spec',
'material_list:unit',
'material_list:inventoryCount',
'material_list:availableCount',
'material_list:files',
'material_list:isEnabled',
'material_list:operation'
]
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 如果用户拥有通配符权限,则不过滤
if 'material_list:*' in user_permissions:
return item_dict
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'id': 'material_list:id',
'companyName': 'material_list:companyName',
'name': 'material_list:name',
'commonName': 'material_list:commonName',
'category': 'material_list:category',
'type': 'material_list:type',
'spec': 'material_list:spec',
'unit': 'material_list:unit',
'inventoryCount': 'material_list:inventoryCount',
'availableCount': 'material_list:availableCount',
'generalManual': 'material_list:files',
'generalImage': 'material_list:files',
'isEnabled': 'material_list:isEnabled'
}
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
# ==============================================================================
# 1. 搜索接口 (GET /api/v1/inbound/base/search)
# ==============================================================================
@inbound_base_bp.route('/search', methods=['GET'])
@permission_required('material_list')
def search_base():
try:
keyword = request.args.get('keyword', '')
data = MaterialBaseService.search_material(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
# 字段级脱敏
user_permissions = get_current_user_permissions()
filtered_data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({"code": 200, "msg": "success", "data": filtered_data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@ -26,6 +99,7 @@ def search_base():
# 2. 列表接口 (GET /api/v1/inbound/base/list)
# ==============================================================================
@inbound_base_bp.route('/list', methods=['GET'])
@permission_required('material_list')
def get_list():
try:
page = request.args.get('pageNum', 1, type=int)
@ -37,10 +111,16 @@ def get_list():
'company': request.args.get('company', ''),
'category': request.args.get('category', ''),
'type': request.args.get('type', ''),
'isEnabled': request.args.get('isEnabled', None)
'isEnabled': request.args.get('isEnabled', None),
'orderByColumn': request.args.get('orderByColumn', ''),
'isAsc': request.args.get('isAsc', None)
}
result = MaterialBaseService.get_list(page, limit, filters)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
@ -51,6 +131,7 @@ def get_list():
# 2.1 选项接口 (GET /api/v1/inbound/base/options)
# ==============================================================================
@inbound_base_bp.route('/options', methods=['GET'])
@permission_required('material_list')
def get_options():
try:
data = MaterialBaseService.get_distinct_options()
@ -64,6 +145,7 @@ def get_options():
# 2.2 导出接口 (GET /api/v1/inbound/base/export)
# ==============================================================================
@inbound_base_bp.route('/export', methods=['GET'])
@permission_required('material_list')
def export_data():
try:
# 获取筛选条件
@ -75,8 +157,11 @@ def export_data():
'isEnabled': request.args.get('isEnabled', None)
}
# 生成 Excel 文件流
file_stream = MaterialBaseService.export_excel(filters)
# 获取当前用户权限
user_permissions = get_current_user_permissions()
# 生成 Excel 文件流(传入用户权限进行脱敏)
file_stream = MaterialBaseService.export_excel(filters, user_permissions)
# 生成文件名:库存统计+年月日+时分秒 (北京时间 UTC+8)
# 简单处理UTC时间 + 8小时
@ -101,13 +186,48 @@ def export_data():
# 3. 新增接口 (POST /api/v1/inbound/base/)
# ==============================================================================
@inbound_base_bp.route('/', methods=['POST'])
@permission_required('material_list:operation')
def create():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
MaterialBaseService.create_material(data)
# 获取当前用户权限
user_permissions = get_current_user_permissions()
# 字段到权限码的映射(与 filter_item_by_permissions 一致)
field_to_perm = {
'id': 'material_list:id',
'companyName': 'material_list:companyName',
'name': 'material_list:name',
'commonName': 'material_list:commonName',
'category': 'material_list:category',
'type': 'material_list:type',
'spec': 'material_list:spec',
'unit': 'material_list:unit',
'inventoryCount': 'material_list:inventoryCount',
'availableCount': 'material_list:availableCount',
'generalManual': 'material_list:files',
'generalImage': 'material_list:files',
'isEnabled': 'material_list:isEnabled'
}
# 过滤用户没有权限的字段
filtered_data = {}
# 如果拥有通配符权限,则不过滤
if 'material_list:*' in user_permissions:
filtered_data = data
else:
for key, value in data.items():
if key in field_to_perm:
perm_code = field_to_perm[key]
if perm_code in user_permissions:
filtered_data[key] = value
# 没有权限则跳过,不包含在 filtered_data 中
else:
# 不在映射中的字段,默认允许(例如 visibilityLevel
filtered_data[key] = value
MaterialBaseService.create_material(filtered_data)
return jsonify({"code": 200, "msg": "新增成功"})
except ValueError as e:
# 捕获业务逻辑验证错误 (如名称为空)
@ -122,10 +242,45 @@ def create():
# 4. 修改接口 (PUT /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['PUT'])
@permission_required('material_list:operation')
def update(id):
try:
data = request.get_json()
MaterialBaseService.update_material(id, data)
# 获取当前用户权限
user_permissions = get_current_user_permissions()
# 字段到权限码的映射(与 filter_item_by_permissions 一致)
field_to_perm = {
'id': 'material_list:id',
'companyName': 'material_list:companyName',
'name': 'material_list:name',
'commonName': 'material_list:commonName',
'category': 'material_list:category',
'type': 'material_list:type',
'spec': 'material_list:spec',
'unit': 'material_list:unit',
'inventoryCount': 'material_list:inventoryCount',
'availableCount': 'material_list:availableCount',
'generalManual': 'material_list:files',
'generalImage': 'material_list:files',
'isEnabled': 'material_list:isEnabled'
}
# 过滤用户没有权限的字段
filtered_data = {}
# 如果拥有通配符权限,则不过滤
if 'material_list:*' in user_permissions:
filtered_data = data
else:
for key, value in data.items():
if key in field_to_perm:
perm_code = field_to_perm[key]
if perm_code in user_permissions:
filtered_data[key] = value
# 没有权限则跳过,不包含在 filtered_data 中
else:
# 不在映射中的字段,默认允许(例如 visibilityLevel
filtered_data[key] = value
# 使用过滤后的数据调用服务
MaterialBaseService.update_material(id, filtered_data)
return jsonify({"code": 200, "msg": "修改成功"})
except Exception as e:
traceback.print_exc()
@ -136,10 +291,11 @@ def update(id):
# 5. 删除接口 (DELETE /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('material_list:operation')
def delete(id):
try:
MaterialBaseService.delete_material(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -1,14 +1,90 @@
from flask import Blueprint, request, jsonify
from app.services.inbound.buy_service import BuyInboundService
from app.utils.decorators import permission_required
import traceback
inbound_buy_bp = Blueprint('stock_buy', __name__)
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
# 返回所有以 inbound_buy: 开头的权限码(这里我们返回一个特殊标记,表示全部)
# 为了简单,我们返回 ['inbound_buy:*'],在过滤函数中特殊处理
return ['inbound_buy:*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'id': 'inbound_buy:id',
'base_id': 'inbound_buy:base_id',
'global_print_id': 'inbound_buy:global_print_id',
'sku': 'inbound_buy:sku',
'barcode': 'inbound_buy:barcode',
'in_date': 'inbound_buy:in_date',
'serial_number': 'inbound_buy:serial_number',
'batch_number': 'inbound_buy:batch_number',
'status': 'inbound_buy:status',
'in_quantity': 'inbound_buy:in_quantity',
'stock_quantity': 'inbound_buy:stock_quantity',
'available_quantity': 'inbound_buy:available_quantity',
'inspection_status': 'inbound_buy:inspection_status',
'warehouse_location': 'inbound_buy:warehouse_location',
'unit_price': 'inbound_buy:unit_price',
'post_tax_unit_price': 'inbound_buy:post_tax_unit_price',
'tax_rate': 'inbound_buy:tax_rate',
'total_price': 'inbound_buy:total_price',
'currency': 'inbound_buy:currency',
'exchange_rate': 'inbound_buy:exchange_rate',
'supplier_name': 'inbound_buy:supplier_name',
'buyer_name': 'inbound_buy:buyer_name',
'buyer_email': 'inbound_buy:buyer_email',
'original_link': 'inbound_buy:original_link',
'detail_link': 'inbound_buy:detail_link',
'arrival_photo': 'inbound_buy:arrival_photo',
'inspection_report': 'inbound_buy:inspection_report',
'material_name': 'inbound_buy:material_name',
'spec_model': 'inbound_buy:spec_model',
'category': 'inbound_buy:category',
'unit': 'inbound_buy:unit',
'material_type': 'inbound_buy:material_type',
'company_name': 'inbound_buy:company_name',
}
# 如果用户是超级管理员且有 'inbound_buy:*',则不过滤
if 'inbound_buy:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
# ------------------------------------------------------------------
# 0. 基础物料搜索
# ------------------------------------------------------------------
@inbound_buy_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_buy')
def search_base():
try:
keyword = request.args.get('keyword', '')
@ -33,6 +109,7 @@ def search_base():
# 1. 获取列表 (修改:接收 category 和 material_type)
# ------------------------------------------------------------------
@inbound_buy_bp.route('/list', methods=['GET'])
@permission_required('inbound_buy')
def get_list():
try:
page = request.args.get('page', 1, type=int)
@ -42,12 +119,17 @@ def get_list():
# 新增筛选参数
category = request.args.get('category', '')
material_type = request.args.get('material_type', '')
company = request.args.get('company', '')
# 状态参数处理
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = BuyInboundService.get_list(page, limit, keyword, statuses, category, material_type)
result = BuyInboundService.get_list(page, limit, keyword, statuses, category, material_type, company)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
@ -58,12 +140,59 @@ def get_list():
# 2. 新增入库
# ------------------------------------------------------------------
@inbound_buy_bp.route('/submit', methods=['POST'])
@permission_required('inbound_buy:operation')
def submit():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data"}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'inbound_buy:*' not in user_permissions:
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'id': 'inbound_buy:id',
'base_id': 'inbound_buy:base_id',
'global_print_id': 'inbound_buy:global_print_id',
'sku': 'inbound_buy:sku',
'barcode': 'inbound_buy:barcode',
'in_date': 'inbound_buy:in_date',
'serial_number': 'inbound_buy:serial_number',
'batch_number': 'inbound_buy:batch_number',
'status': 'inbound_buy:status',
'in_quantity': 'inbound_buy:in_quantity',
'stock_quantity': 'inbound_buy:stock_quantity',
'available_quantity': 'inbound_buy:available_quantity',
'inspection_status': 'inbound_buy:inspection_status',
'warehouse_location': 'inbound_buy:warehouse_location',
'unit_price': 'inbound_buy:unit_price',
'post_tax_unit_price': 'inbound_buy:post_tax_unit_price',
'tax_rate': 'inbound_buy:tax_rate',
'total_price': 'inbound_buy:total_price',
'currency': 'inbound_buy:currency',
'exchange_rate': 'inbound_buy:exchange_rate',
'supplier_name': 'inbound_buy:supplier_name',
'buyer_name': 'inbound_buy:buyer_name',
'buyer_email': 'inbound_buy:buyer_email',
'original_link': 'inbound_buy:original_link',
'detail_link': 'inbound_buy:detail_link',
'arrival_photo': 'inbound_buy:arrival_photo',
'inspection_report': 'inbound_buy:inspection_report',
'material_name': 'inbound_buy:material_name',
'spec_model': 'inbound_buy:spec_model',
'category': 'inbound_buy:category',
'unit': 'inbound_buy:unit',
'material_type': 'inbound_buy:material_type',
'company_name': 'inbound_buy:company_name',
}
# 复制一份,避免遍历时修改字典
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
new_stock = BuyInboundService.handle_inbound(data)
return jsonify({
@ -80,9 +209,55 @@ def submit():
# 3. 更新入库
# ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_buy:operation')
def update_buy(id):
try:
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'inbound_buy:*' not in user_permissions:
field_to_perm = {
'id': 'inbound_buy:id',
'base_id': 'inbound_buy:base_id',
'global_print_id': 'inbound_buy:global_print_id',
'sku': 'inbound_buy:sku',
'barcode': 'inbound_buy:barcode',
'in_date': 'inbound_buy:in_date',
'serial_number': 'inbound_buy:serial_number',
'batch_number': 'inbound_buy:batch_number',
'status': 'inbound_buy:status',
'in_quantity': 'inbound_buy:in_quantity',
'stock_quantity': 'inbound_buy:stock_quantity',
'available_quantity': 'inbound_buy:available_quantity',
'inspection_status': 'inbound_buy:inspection_status',
'warehouse_location': 'inbound_buy:warehouse_location',
'unit_price': 'inbound_buy:unit_price',
'post_tax_unit_price': 'inbound_buy:post_tax_unit_price',
'tax_rate': 'inbound_buy:tax_rate',
'total_price': 'inbound_buy:total_price',
'currency': 'inbound_buy:currency',
'exchange_rate': 'inbound_buy:exchange_rate',
'supplier_name': 'inbound_buy:supplier_name',
'buyer_name': 'inbound_buy:buyer_name',
'buyer_email': 'inbound_buy:buyer_email',
'original_link': 'inbound_buy:original_link',
'detail_link': 'inbound_buy:detail_link',
'arrival_photo': 'inbound_buy:arrival_photo',
'inspection_report': 'inbound_buy:inspection_report',
'material_name': 'inbound_buy:material_name',
'spec_model': 'inbound_buy:spec_model',
'category': 'inbound_buy:category',
'unit': 'inbound_buy:unit',
'material_type': 'inbound_buy:material_type',
'company_name': 'inbound_buy:company_name',
}
# 复制一份,避免遍历时修改字典
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
BuyInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
@ -93,6 +268,7 @@ def update_buy(id):
# 4. 删除
# ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_buy:operation')
def delete_buy(id):
try:
BuyInboundService.delete_inbound(id)
@ -105,6 +281,7 @@ def delete_buy(id):
# 5. [新增] 获取筛选下拉选项 (修复404的关键)
# ------------------------------------------------------------------
@inbound_buy_bp.route('/options', methods=['GET'])
@permission_required('inbound_buy')
def get_options():
try:
data = BuyInboundService.get_filter_options()
@ -117,6 +294,7 @@ def get_options():
# 6. 获取关联的出库历史 (如果有)
# ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_buy')
def get_history(id):
# 如果没有出库模块,这个接口可能为空,但为保持兼容性保留
return jsonify({"code": 200, "msg": "success", "data": []})
@ -126,6 +304,7 @@ def get_history(id):
# 7. 供应商建议
# ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/suppliers', methods=['GET'])
@permission_required('inbound_buy')
def get_supplier_suggestions():
base_id = request.args.get('base_id', type=int)
if not base_id:
@ -138,6 +317,7 @@ def get_supplier_suggestions():
# 8. 采购人建议 (全局)
# ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_buy')
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = BuyInboundService.get_history_purchasers(keyword)
@ -148,6 +328,7 @@ def get_user_suggestions():
# 9. 链接建议
# ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/links', methods=['GET'])
@permission_required('inbound_buy')
def get_link_suggestions():
base_id = request.args.get('base_id', type=int)
link_type = request.args.get('type', 'original') # original or detail
@ -161,9 +342,10 @@ def get_link_suggestions():
# 10. 库位建议
# ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/locations', methods=['GET'])
@permission_required('inbound_buy')
def get_location_suggestions():
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({"code": 400, "msg": "base_id required"}), 400
data = BuyInboundService.get_history_locations(base_id)
return jsonify({"code": 200, "msg": "success", "data": data})
return jsonify({"code": 200, "msg": "success", "data": data})

View File

@ -1,109 +1,129 @@
# inventory-backend/app/api/v1/inbound/product.py
from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService
from app.utils.decorators import permission_required
import traceback
# === 这一行非常关键,绝对不能丢!===
inbound_product_bp = Blueprint('stock_product', __name__)
def get_current_user_permissions():
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
if not user_role: return []
if user_role.upper() == 'SUPER_ADMIN': return ['inbound_product:*']
perm_dict = AuthService.get_user_permissions(user_role)
return perm_dict.get('menus', []) + perm_dict.get('elements', [])
def filter_item_by_permissions(item_dict, user_permissions):
field_to_perm = {
'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id', 'company_name': 'inbound_product:company_name',
'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category',
'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model',
'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date',
'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number',
'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status',
'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity',
'available_quantity': 'inbound_product:available_quantity', 'warehouse_location': 'inbound_product:warehouse_location',
'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version',
'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id',
'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time',
'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost',
'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price',
'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link',
'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link',
}
if 'inbound_product:*' in user_permissions: return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions: item_dict[field] = None
return item_dict
# ------------------------------------------------------------------
# 0. 基础物料搜索 (关键接口:配合 Service 实现自动回填)
# ------------------------------------------------------------------
@inbound_product_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_product')
def search_base():
"""
对应前端 API: /inbound/product/search-base
功能: 模糊搜索基础物料,返回 spec, unit, category, type 等详细信息
"""
try:
keyword = request.args.get('keyword', '')
# 调用 Service 层已修复的 search_base_material 方法
data = ProductInboundService.search_base_material(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
# 捕获异常并打印堆栈,方便调试
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 0.5 [新增] BOM 搜索接口
# ------------------------------------------------------------------
@inbound_product_bp.route('/search-bom', methods=['GET'])
def search_bom():
"""
供前端下拉框远程搜索使用 (搜索BOM)
"""
try:
keyword = request.args.get('keyword', '')
data = ProductInboundService.search_bom_options(keyword)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 1. 获取列表 (支持 status 多选筛选)
# ------------------------------------------------------------------
@inbound_product_bp.route('/list', methods=['GET'])
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
keyword = request.args.get('keyword', '')
# 接收状态参数 (逗号分隔字符串 -> 列表)
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = ProductInboundService.get_list(page, limit, keyword, statuses)
result = ProductInboundService.search_base_material(keyword, page)
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增入库
# ------------------------------------------------------------------
@inbound_product_bp.route('/submit', methods=['POST'])
def submit():
@inbound_product_bp.route('/search-bom', methods=['GET'])
@permission_required('inbound_product')
def search_bom():
try:
# 调用 Service 处理入库,获取新创建的对象
new_stock = ProductInboundService.handle_inbound(request.get_json())
# 返回成功信息以及新创建的数据包含生成的ID和SKU供前端自动打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
keyword = request.args.get('keyword', '')
data = ProductInboundService.search_bom_options(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_product_bp.route('/list', methods=['GET'])
@permission_required('inbound_product')
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
keyword = request.args.get('keyword', '')
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
category = request.args.get('category', '')
material_type = request.args.get('material_type', '')
company = request.args.get('company', '')
result = ProductInboundService.get_list(page, limit, keyword, statuses, category, material_type, company)
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_product_bp.route('/submit', methods=['POST'])
@permission_required('inbound_product:operation')
def submit():
try:
data = request.get_json()
if not data: return jsonify({"code": 400, "msg": "No data"}), 400
user_permissions = get_current_user_permissions()
if 'inbound_product:*' not in user_permissions:
field_to_perm = {'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id', 'company_name': 'inbound_product:company_name', 'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category', 'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model', 'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date', 'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number', 'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status', 'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity', 'available_quantity': 'inbound_product:available_quantity', 'warehouse_location': 'inbound_product:warehouse_location', 'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version', 'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id', 'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time', 'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost', 'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price', 'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link', 'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
new_stock = ProductInboundService.handle_inbound(data)
return jsonify({"code": 200, "msg": "入库成功", "data": new_stock.to_dict()})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新入库
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_product:operation')
def update(id):
try:
ProductInboundService.update_inbound(id, request.get_json())
data = request.get_json()
user_permissions = get_current_user_permissions()
if 'inbound_product:*' not in user_permissions:
field_to_perm = {'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id', 'company_name': 'inbound_product:company_name', 'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category', 'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model', 'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date', 'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number', 'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status', 'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity', 'available_quantity': 'inbound_product:available_quantity', 'warehouse_location': 'inbound_product:warehouse_location', 'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version', 'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id', 'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time', 'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost', 'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price', 'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link', 'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
ProductInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_product:operation')
def delete(id):
try:
ProductInboundService.delete_inbound(id)
@ -112,11 +132,8 @@ def delete(id):
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. 获取出库历史
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_product')
def get_history(id):
try:
data = ProductInboundService.get_outbound_history(id)
@ -125,24 +142,29 @@ def get_history(id):
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 6. 系统用户建议
# ------------------------------------------------------------------
@inbound_product_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_product')
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ProductInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 7. 获取筛选选项
# ------------------------------------------------------------------
@inbound_product_bp.route('/options', methods=['GET'])
@permission_required('inbound_product')
def get_options():
try:
data = ProductInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_product_bp.route('/suggestions/managers', methods=['GET'])
@permission_required('inbound_product')
def get_manager_history():
keyword = request.args.get('keyword', '')
try:
data = ProductInboundService.get_history_managers(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -1,120 +1,126 @@
# inventory-backend/app/api/v1/inbound/semi.py
from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService
from app.utils.decorators import permission_required
import traceback
# 定义蓝图
# === 这一行非常关键,绝对不能丢!===
inbound_semi_bp = Blueprint('stock_semi', __name__)
def get_current_user_permissions():
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
if not user_role: return []
if user_role.upper() == 'SUPER_ADMIN': return ['inbound_semi:*']
perm_dict = AuthService.get_user_permissions(user_role)
return perm_dict.get('menus', []) + perm_dict.get('elements', [])
def filter_item_by_permissions(item_dict, user_permissions):
field_to_perm = {
'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name',
'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category',
'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model',
'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date',
'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number',
'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status',
'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity',
'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity',
'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code',
'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code',
'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost',
'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager',
'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time',
'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link',
'detail_link': 'inbound_semi:detail_link',
}
if 'inbound_semi:*' in user_permissions: return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions: item_dict[field] = None
return item_dict
# ------------------------------------------------------------------
# 0. 基础物料搜索 (复用逻辑)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_semi')
def search_base():
"""
供前端下拉框远程搜索使用 (搜索半成品类型的基础物料)
Query Param: keyword (名称或规格)
"""
try:
keyword = request.args.get('keyword', '')
# 这里复用 Service 中的搜索逻辑
data = SemiInboundService.search_base_material(keyword)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 0.5 [新增] BOM 搜索接口
# ------------------------------------------------------------------
@inbound_semi_bp.route('/search-bom', methods=['GET'])
def search_bom():
"""
供前端下拉框远程搜索使用 (搜索BOM)
Query Param: keyword (编号或父件规格)
"""
try:
keyword = request.args.get('keyword', '')
data = SemiInboundService.search_bom_options(keyword)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 1. 获取半成品列表
# ------------------------------------------------------------------
@inbound_semi_bp.route('/list', methods=['GET'])
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
# 支持按关键字搜索BOM号、工单号、SN、批号等
keyword = request.args.get('keyword', '')
# [修改] 获取状态列表参数
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = SemiInboundService.get_list(page, limit, keyword, statuses)
result = SemiInboundService.search_base_material(keyword, page)
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增半成品入库 (修改:返回创建的对象数据)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/submit', methods=['POST'])
def submit():
@inbound_semi_bp.route('/search-bom', methods=['GET'])
@permission_required('inbound_semi')
def search_bom():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data"}), 400
# 修改:调用 Service 处理入库,获取新创建的对象
new_stock = SemiInboundService.handle_inbound(data)
# 修改返回成功信息以及新创建的数据包含生成的ID和SKU供前端打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
keyword = request.args.get('keyword', '')
data = SemiInboundService.search_bom_options(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/list', methods=['GET'])
@permission_required('inbound_semi')
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
keyword = request.args.get('keyword', '')
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = SemiInboundService.get_list(page, limit, keyword, statuses)
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/submit', methods=['POST'])
@permission_required('inbound_semi:operation')
def submit():
try:
data = request.get_json()
if not data: return jsonify({"code": 400, "msg": "No data"}), 400
user_permissions = get_current_user_permissions()
if 'inbound_semi:*' not in user_permissions:
field_to_perm = {'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name', 'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category', 'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model', 'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date', 'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number', 'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status', 'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity', 'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity', 'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code', 'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code', 'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost', 'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager', 'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time', 'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link', 'detail_link': 'inbound_semi:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
new_stock = SemiInboundService.handle_inbound(data)
return jsonify({"code": 200, "msg": "入库成功", "data": new_stock.to_dict()})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新半成品入库信息
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_semi:operation')
def update_semi(id):
try:
data = request.get_json()
user_permissions = get_current_user_permissions()
if 'inbound_semi:*' not in user_permissions:
field_to_perm = {'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name', 'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category', 'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model', 'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date', 'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number', 'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status', 'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity', 'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity', 'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code', 'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code', 'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost', 'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager', 'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time', 'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link', 'detail_link': 'inbound_semi:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除半成品入库记录
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_semi:operation')
def delete_semi(id):
try:
SemiInboundService.delete_inbound(id)
@ -123,41 +129,39 @@ def delete_semi(id):
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. [新增] 获取关联出库历史
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_semi')
def get_history(id):
try:
data = SemiInboundService.get_outbound_history(id)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 6. 系统用户建议
# ------------------------------------------------------------------
@inbound_semi_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_semi')
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = SemiInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 7. 获取筛选选项
# ------------------------------------------------------------------
@inbound_semi_bp.route('/options', methods=['GET'])
@permission_required('inbound_semi')
def get_options():
try:
data = SemiInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/suggestions/managers', methods=['GET'])
@permission_required('inbound_semi')
def get_manager_history():
keyword = request.args.get('keyword', '')
try:
data = SemiInboundService.get_history_managers(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -3,21 +3,73 @@ from flask import request, jsonify, current_app
from flask_jwt_extended import jwt_required
from . import inbound_bp
from app.services.inbound.service_service import ServiceService
from app.utils.decorators import role_required
from app.utils.decorators import role_required, permission_required
import traceback
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['inbound_service:*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'id': 'inbound_service:id',
'base_id': 'inbound_service:base_id',
'sku': 'inbound_service:sku',
'material_name': 'inbound_service:material_name',
'provider_name': 'inbound_service:provider_name',
'sale_price': 'inbound_service:sale_price',
'description': 'inbound_service:description',
'created_at': 'inbound_service:created_at',
'material_type': 'inbound_service:material_type',
'category': 'inbound_service:category',
'spec_model': 'inbound_service:spec_model',
'unit': 'inbound_service:unit',
}
if 'inbound_service:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
@inbound_bp.route('/service/search-base', methods=['GET'])
@jwt_required()
@permission_required('inbound_service')
def search_base():
"""搜索基础物料"""
keyword = request.args.get('keyword', '')
try:
data = ServiceService.search_base_material(keyword)
user_permissions = get_current_user_permissions()
filtered_data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({
'code': 200,
'msg': 'success',
'data': data
'data': filtered_data
})
except Exception as e:
current_app.logger.error(f'搜索基础物料失败: {str(e)}')
@ -25,7 +77,7 @@ def search_base():
@inbound_bp.route('/service', methods=['GET'])
@jwt_required()
@permission_required('inbound_service')
def get_service_list():
"""获取服务权益列表"""
page = request.args.get('page', 1, type=int)
@ -44,6 +96,9 @@ def get_service_list():
end_date=end_date,
provider_name=provider_name
)
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({
'code': 200,
'msg': 'success',
@ -56,14 +111,38 @@ def get_service_list():
@inbound_bp.route('/service', methods=['POST'])
@jwt_required()
@role_required('admin,manager')
@permission_required('inbound_service:operation')
def create_service():
"""创建服务权益"""
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'inbound_service:*' not in user_permissions:
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'id': 'inbound_service:id',
'base_id': 'inbound_service:base_id',
'sku': 'inbound_service:sku',
'material_name': 'inbound_service:material_name',
'provider_name': 'inbound_service:provider_name',
'sale_price': 'inbound_service:sale_price',
'description': 'inbound_service:description',
'created_at': 'inbound_service:created_at',
'material_type': 'inbound_service:material_type',
'category': 'inbound_service:category',
'spec_model': 'inbound_service:spec_model',
'unit': 'inbound_service:unit',
}
# 复制一份,避免遍历时修改字典
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
# 基础校验
if not data.get('base_id'):
return jsonify({'code': 400, 'msg': '请选择基础物料'}), 400
@ -72,10 +151,12 @@ def create_service():
try:
service = ServiceService.create_service(data)
user_permissions = get_current_user_permissions()
filtered_data = filter_item_by_permissions(service.to_dict(), user_permissions)
return jsonify({
'code': 201,
'msg': '创建成功',
'data': service.to_dict()
'data': filtered_data
}), 201
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
@ -86,15 +167,17 @@ def create_service():
@inbound_bp.route('/service/<int:service_id>', methods=['GET'])
@jwt_required()
@permission_required('inbound_service')
def get_service(service_id):
"""获取单个服务权益详情"""
try:
service = ServiceService.get_service(service_id)
user_permissions = get_current_user_permissions()
filtered_data = filter_item_by_permissions(service.to_dict(), user_permissions)
return jsonify({
'code': 200,
'msg': 'success',
'data': service.to_dict()
'data': filtered_data
})
except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404
@ -104,14 +187,38 @@ def get_service(service_id):
@inbound_bp.route('/service/<int:service_id>', methods=['PUT'])
@jwt_required()
@role_required('admin,manager')
@permission_required('inbound_service:operation')
def update_service(service_id):
"""更新服务权益"""
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'inbound_service:*' not in user_permissions:
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'id': 'inbound_service:id',
'base_id': 'inbound_service:base_id',
'sku': 'inbound_service:sku',
'material_name': 'inbound_service:material_name',
'provider_name': 'inbound_service:provider_name',
'sale_price': 'inbound_service:sale_price',
'description': 'inbound_service:description',
'created_at': 'inbound_service:created_at',
'material_type': 'inbound_service:material_type',
'category': 'inbound_service:category',
'spec_model': 'inbound_service:spec_model',
'unit': 'inbound_service:unit',
}
# 复制一份,避免遍历时修改字典
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
# 允许更新的字段
allowed_fields = {
'sale_price', 'provider_name', 'description',
@ -124,10 +231,12 @@ def update_service(service_id):
try:
service = ServiceService.update_service(service_id, filtered_data)
user_permissions = get_current_user_permissions()
filtered_service = filter_item_by_permissions(service.to_dict(), user_permissions)
return jsonify({
'code': 200,
'msg': '更新成功',
'data': service.to_dict()
'data': filtered_service
})
except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404
@ -137,8 +246,7 @@ def update_service(service_id):
@inbound_bp.route('/service/<int:service_id>', methods=['DELETE'])
@jwt_required()
@role_required('admin,manager')
@permission_required('inbound_service:operation')
def delete_service(service_id):
"""删除服务权益"""
try:
@ -155,7 +263,7 @@ def delete_service(service_id):
@inbound_bp.route('/service/suggestions/providers', methods=['GET'])
@jwt_required()
@permission_required('inbound_service')
def get_provider_suggestions():
base_id = request.args.get('base_id', type=int)
if not base_id:
@ -165,7 +273,7 @@ def get_provider_suggestions():
@inbound_bp.route('/service/suggestions/users', methods=['GET'])
@jwt_required()
@permission_required('inbound_service')
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ServiceService.search_system_users(keyword)
@ -173,10 +281,10 @@ def get_user_suggestions():
@inbound_bp.route('/service/options', methods=['GET'])
@jwt_required()
@permission_required('inbound_service')
def get_options():
try:
data = ServiceService.get_filter_options()
return jsonify({'code': 200, 'msg': 'success', 'data': data})
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -2,6 +2,7 @@ from flask import Blueprint, jsonify, request
from app.extensions import db
# ★★★ 修复点:必须引入 datetime否则下方更新时间时会报错 500 ★★★
from datetime import datetime
from app.utils.decorators import permission_required
# 导入模型
from app.models.inbound.buy import StockBuy
@ -24,6 +25,7 @@ bp = Blueprint('stock_ops', __name__)
@bp.route('/all', methods=['GET'])
@permission_required('inventory_stocktake')
def get_all_stock():
"""
获取所有库存 > 0 的物品
@ -63,6 +65,7 @@ def get_all_stock():
# --- 草稿箱接口 ---
@bp.route('/draft/list', methods=['GET'])
@permission_required('inventory_stocktake')
def get_drafts():
"""获取当前用户的盘点进度"""
user_id = request.args.get('user_id', 'admin')
@ -71,6 +74,7 @@ def get_drafts():
@bp.route('/draft/add', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def add_draft():
"""扫码同步 (支持更新数量)"""
try:
@ -100,6 +104,7 @@ def add_draft():
@bp.route('/draft/clear', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def clear_draft():
"""清空进度"""
data = request.json
@ -110,9 +115,26 @@ def clear_draft():
return jsonify({"message": "Cleared"}), 200
@bp.route('/borrowed-quantities', methods=['POST'])
@permission_required('inventory_stocktake')
def get_borrowed_quantities():
"""批量获取借出未还数量"""
from app.models.transaction import TransBorrow
data = request.json.get('items', [])
result = {}
for item in data:
source = item.get('source_table')
stock_id = item.get('stock_id')
if source and stock_id is not None:
qty = TransBorrow.get_borrowed_quantity(source, stock_id)
result[f"{source}_{stock_id}"] = qty
return jsonify(result), 200
# --- 打印接口 ---
@bp.route('/print/selection', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def print_selection():
try:
data = request.json
@ -126,6 +148,7 @@ def print_selection():
@bp.route('/print/stocktake', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def print_stocktake():
try:
data = request.json
@ -133,4 +156,4 @@ def print_stocktake():
success, msg = printer.print_stocktake_report(data)
return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500
return jsonify({"message": str(e)}), 500

View File

@ -1,17 +1,80 @@
from flask import Blueprint, request, jsonify
from app.services.outbound_service import OutboundService
from flask_jwt_extended import jwt_required, get_jwt_identity
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required
from app.services.auth_service import AuthService
import traceback
outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound')
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['outbound_list:*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'outbound_no': 'outbound_list:outbound_no',
'outbound_time': 'outbound_list:outbound_time',
'outbound_type': 'outbound_list:outbound_type',
'total_amount': 'outbound_list:total_amount',
'consumer_name': 'outbound_list:consumer_name',
'operator_name': 'outbound_list:operator_name',
'remark': 'outbound_list:remark',
'signature_path': 'outbound_list:signature_path',
# 明细字段
'sku': 'outbound_list:sku',
'name': 'outbound_list:name',
'material_type': 'outbound_list:material_type',
'category': 'outbound_list:category',
'spec_model': 'outbound_list:spec_model',
'quantity': 'outbound_list:quantity',
'unit_price': 'outbound_list:unit_price',
'subtotal': 'outbound_list:subtotal',
}
# 如果用户是超级管理员且有 'outbound_list:*',则不过滤
if 'outbound_list:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
# 如果 item_dict 中包含 items 列表,递归处理每个子项
if 'items' in item_dict and isinstance(item_dict['items'], list):
for sub_item in item_dict['items']:
filter_item_by_permissions(sub_item, user_permissions)
return item_dict
# --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/outbound/scan?barcode=...
# --------------------------------------------------------
@outbound_bp.route('/scan', methods=['GET'])
@jwt_required()
@permission_required('outbound_selection')
def scan_barcode():
barcode = request.args.get('barcode')
if not barcode:
@ -45,6 +108,19 @@ def scan_barcode():
@outbound_bp.route('', methods=['POST'])
@jwt_required()
def create_outbound():
# 权限检查:需要 outbound_create:operation 或 outbound_selection:operation 之一
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return jsonify({'code': 403, 'msg': '未授权'}), 403
# 超级管理员直接放行
if user_role.upper() != 'SUPER_ADMIN':
perm_dict = AuthService.get_user_permissions(user_role)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if ('outbound_create:operation' not in perms) and ('outbound_selection:operation' not in perms):
return jsonify({'code': 403, 'msg': '权限不足'}), 403
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
@ -67,6 +143,44 @@ def create_outbound():
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'outbound_list:*' not in user_permissions:
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'outbound_no': 'outbound_list:outbound_no',
'outbound_time': 'outbound_list:outbound_time',
'outbound_type': 'outbound_list:outbound_type',
'total_amount': 'outbound_list:total_amount',
'consumer_name': 'outbound_list:consumer_name',
'operator_name': 'outbound_list:operator_name',
'remark': 'outbound_list:remark',
'signature_path': 'outbound_list:signature_path',
# 明细字段
'sku': 'outbound_list:sku',
'name': 'outbound_list:name',
'material_type': 'outbound_list:material_type',
'category': 'outbound_list:category',
'spec_model': 'outbound_list:spec_model',
'quantity': 'outbound_list:quantity',
'unit_price': 'outbound_list:unit_price',
'price': 'outbound_list:unit_price', # 兼容 price 字段
'subtotal': 'outbound_list:subtotal',
}
# 清洗顶层字段
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
# 清洗 items 中的每个商品字段
if 'items' in data and isinstance(data['items'], list):
for item in data['items']:
for field in list(item.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
item.pop(field, None)
try:
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
@ -89,6 +203,7 @@ def create_outbound():
# --------------------------------------------------------
@outbound_bp.route('', methods=['GET'])
@jwt_required()
@permission_required('outbound_list')
def get_outbound_list():
try:
page = int(request.args.get('page', 1))
@ -99,6 +214,11 @@ def get_outbound_list():
# ★ [修改] 调用分组查询服务
result = OutboundService.get_grouped_list(page, limit, keyword)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({
'code': 200,
'msg': '获取成功',
@ -106,4 +226,4 @@ def get_outbound_list():
})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -0,0 +1,48 @@
# inventory-backend/app/api/v1/permission.py
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required
from app.services.permission_service import PermissionService
permission_bp = Blueprint('permission', __name__)
@permission_bp.route('/tree', methods=['GET'])
@jwt_required()
def get_tree():
"""获取权限树"""
try:
data = PermissionService.get_permission_tree()
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
except Exception as e:
# 打印详细错误到控制台,方便调试
current_app.logger.error(f"Get Tree Failed: {str(e)}")
# 返回 500 时带上错误信息
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
@permission_bp.route('/role/<string:role_code>', methods=['GET'])
@jwt_required()
def get_role_perms(role_code):
"""获取某个角色的权限列表"""
try:
data = PermissionService.get_role_permissions(role_code)
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
except Exception as e:
current_app.logger.error(f"Get Role Perms Failed: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500
@permission_bp.route('/assign', methods=['POST'])
@jwt_required()
def assign_perms():
"""保存权限分配"""
try:
data = request.get_json()
role_code = data.get('role_code')
permissions = data.get('permissions', []) # list of codes
PermissionService.assign_permissions(role_code, permissions)
return jsonify({'code': 200, 'msg': '保存成功'}), 200
except Exception as e:
current_app.logger.error(f"Assign Perms Failed: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -1,16 +1,86 @@
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
from flask_jwt_extended import jwt_required, get_jwt_identity
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required
from app.services.auth_service import AuthService
from app.services.trans_service import TransService
import traceback
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
claims = get_jwt()
user_role = claims.get('role')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['*']
perm_dict = AuthService.get_user_permissions(user_role)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'borrow_no': f'{prefix}:borrow_no',
'borrower_name': f'{prefix}:borrower_name',
'sku': f'{prefix}:sku',
'borrow_time': f'{prefix}:borrow_time',
'return_time': f'{prefix}:return_time',
'status': f'{prefix}:status',
'expected_return_time': f'{prefix}:expected_return_time',
'return_location': f'{prefix}:return_location',
'borrow_signature': f'{prefix}:borrow_signature',
'return_signature': f'{prefix}:return_signature',
}
# 如果用户是超级管理员且有 '*',则不过滤
if '*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
# --- 借库接口 ---
@trans_bp.route('/borrow', methods=['POST'])
@jwt_required()
@permission_required('op_borrow:operation')
def create_borrow():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
@ -21,6 +91,7 @@ def create_borrow():
# --- 还库辅助:扫码查找借出记录 ---
@trans_bp.route('/return/scan', methods=['GET'])
@jwt_required()
@permission_required('op_return')
def scan_borrowed_item():
barcode = request.args.get('barcode')
if not barcode:
@ -36,8 +107,29 @@ def scan_borrowed_item():
# --- 还库提交 ---
@trans_bp.route('/return', methods=['POST'])
@jwt_required()
@permission_required('op_return:operation')
def submit_return():
data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if '*' not in user_permissions:
field_to_perm = {
'borrow_no': 'op_records:borrow_no',
'borrower_name': 'op_records:borrower_name',
'sku': 'op_records:sku',
'borrow_time': 'op_records:borrow_time',
'return_time': 'op_records:return_time',
'status': 'op_records:status',
'expected_return_time': 'op_records:expected_return_time',
'return_location': 'op_records:return_location',
'borrow_signature': 'op_records:borrow_signature',
'return_signature': 'op_records:return_signature',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
data.pop(field, None)
user = get_jwt_identity() # 库管
try:
TransService.process_return(data, operator_name=user)
@ -49,10 +141,15 @@ def submit_return():
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()
@permission_required('op_records')
def get_records():
status = request.args.get('status', 'all')
page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '')
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if res.get('items'):
res['items'] = [filter_item_by_permissions(item, user_permissions, 'op_records') for item in res['items']]
return jsonify({'code': 200, 'data': res})

View File

@ -33,7 +33,8 @@ class StockBuy(db.Model):
available_quantity = db.Column(db.Numeric(19, 4), default=0)
# 财务与商务
unit_price = db.Column(db.Numeric(19, 4), default=0) # 现意为:不含税单价
pre_tax_unit_price = db.Column(db.Numeric(19, 4), default=0) # 现意为:不含税单价
post_tax_unit_price = db.Column(db.Numeric(19, 4), default=0) # 税后单价
total_price = db.Column(db.Numeric(19, 4), default=0) # 总价
# [新增] 税率
tax_rate = db.Column(db.Numeric(5, 2), default=0)
@ -97,7 +98,8 @@ class StockBuy(db.Model):
'available_quantity': float(self.available_quantity or 0),
'qty_available': float(self.available_quantity or 0),
'unit_price': float(self.unit_price or 0),
'unit_price': float(self.pre_tax_unit_price or 0),
'post_tax_unit_price': float(self.post_tax_unit_price or 0),
'total_price': float(self.total_price or 0),
# [新增] 税率
'tax_rate': float(self.tax_rate or 0),
@ -116,4 +118,4 @@ class StockBuy(db.Model):
'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
}
}

View File

@ -1,14 +1,17 @@
# app/models/system.py
# inventory-backend/app/models/system.py
from app.extensions import db
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
# ==========================================
# 1. 系统用户表
# ==========================================
class SysUser(db.Model):
"""
系统用户表
对应数据库: sys_user
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan)
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan01)
"""
__tablename__ = 'sys_user'
@ -19,8 +22,7 @@ class SysUser(db.Model):
role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text)
# created_at 已在数据库脚本中移除,此处不再定义
created_at = db.Column(db.DateTime, default=datetime.now)
def set_password(self, password):
"""生成加密密码"""
@ -45,23 +47,27 @@ class SysUser(db.Model):
parts = raw_name.split('/')
real_name = parts[0]
acc_id = parts[1]
# 格式化为前端展示格式: 张三(zhangsan)
# 格式化为前端展示格式: 张三(zhangsan01)
display_name = f"{real_name}({acc_id})"
# 单独提取账号ID (如果前端需要单独用)
account_id = acc_id
return {
'id': self.id,
'username': display_name, # 列表显示: 张三(zhangsan)
'username': display_name, # 列表显示: 张三(zhangsan01)
'raw_username': self.username, # 原始数据
'account_id': account_id, # 纯账号ID: zhangsan
'account_id': account_id, # 纯账号ID: zhangsan01
'email': self.email,
'department': self.department,
'role': self.role,
'status': self.status
'status': self.status,
'created_at': self.created_at.isoformat() if self.created_at else None
}
# ==========================================
# 2. 系统日志表
# ==========================================
class SysLog(db.Model):
"""
系统操作日志表
@ -88,4 +94,58 @@ class SysLog(db.Model):
'module_name': self.module_name,
'action_type': self.action_type,
'description': self.description
}
}
# ==========================================
# 3. 权限管理模型 (RBAC) - [新增]
# ==========================================
class SysMenu(db.Model):
"""系统菜单/页面表"""
__tablename__ = 'sys_menu'
id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, default=0)
name = db.Column(db.String(50), nullable=False)
code = db.Column(db.String(100), unique=True, nullable=False)
path = db.Column(db.String(200))
sort_order = db.Column(db.Integer, default=0)
is_visible = db.Column(db.Boolean, default=True)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'path': self.path,
'type': 'menu' # 前端树形控件图标判断用
}
class SysElement(db.Model):
"""页面元素/列定义表"""
__tablename__ = 'sys_element'
id = db.Column(db.Integer, primary_key=True)
menu_code = db.Column(db.String(100), db.ForeignKey('sys_menu.code'))
name = db.Column(db.String(100), nullable=False)
code = db.Column(db.String(100), nullable=False) # 如: unit_price
element_type = db.Column(db.String(20), default='column')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'code': self.code,
'menu_code': self.menu_code,
'type': 'element',
'element_type': self.element_type
}
class SysRolePermission(db.Model):
"""角色权限关联表"""
__tablename__ = 'sys_role_permission'
id = db.Column(db.Integer, primary_key=True)
role_code = db.Column(db.String(50), nullable=False)
target_code = db.Column(db.String(100), nullable=False) # menu_code 或 element_code
type = db.Column(db.String(20), nullable=False) # 'menu' 或 'element'

View File

@ -1,5 +1,6 @@
from app.extensions import db
from datetime import datetime
from sqlalchemy import func
class TransBorrow(db.Model):
@ -46,6 +47,19 @@ class TransBorrow(db.Model):
'remark': self.remark,
}
@classmethod
def get_borrowed_quantity(cls, source_table, stock_id):
"""
获取指定库存记录source_table 和 stock_id的借出未还数量总和。
返回浮点数,若无借出记录则返回 0.0。
"""
result = db.session.query(func.sum(cls.quantity)).filter(
cls.source_table == source_table,
cls.stock_id == stock_id,
cls.is_returned == False
).scalar()
return float(result) if result is not None else 0.0
class TransRepair(db.Model):
__tablename__ = 'trans_repair'

View File

@ -1,11 +1,11 @@
# app/services/auth_service.py
from app.models.system import SysUser
from app.models.system import SysUser, SysRolePermission # <== 引入 SysRolePermission
from app.extensions import db
from sqlalchemy import func
from flask_jwt_extended import create_access_token
from app.utils.constants import UserRole
from datetime import timedelta
class AuthService:
# 硬编码的超级管理员凭证
SUPER_ADMIN_USER = "IRIS"
@ -52,9 +52,10 @@ class AuthService:
if user.status != 'active':
raise ValueError("账号已被禁用,请联系管理员")
user_role = user.role
user_role = user.role.upper() if user.role else None
user_id = user.id
user_info = user.to_dict()
user_info['role'] = user_role
# 3. 生成 Token
# Token 中 identity 存数据库IDclaims 存登录账号ID
@ -81,7 +82,9 @@ class AuthService:
创建新用户
data 包含: cn_name(张三), username(zhangsan), ...
"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
# 标准化操作者角色为全大写
operator_role_upper = operator_role.upper() if operator_role else None
if operator_role_upper not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
cn_name = data.get('cn_name')
@ -90,7 +93,8 @@ class AuthService:
if not cn_name or not pinyin_base:
raise Exception("姓名和账号不能为空")
role = data.get('role')
role_raw = data.get('role')
role = role_raw.upper() if role_raw else None
# 验证角色合法性
valid_roles = [
@ -101,7 +105,7 @@ class AuthService:
if role not in valid_roles:
raise Exception(f"角色无效")
if operator_role == UserRole.SUPERVISOR and role == UserRole.SUPER_ADMIN:
if operator_role_upper == UserRole.SUPERVISOR and role == UserRole.SUPER_ADMIN:
raise Exception("权限不足:主管无法创建超级管理员")
email = data.get('email', '')
@ -150,7 +154,9 @@ class AuthService:
更新用户信息
注意: 这里暂时不允许修改用户名/账号,因为涉及 split 逻辑较复杂,且通常账号不开通后不改
"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
# 标准化操作者角色为全大写
operator_role_upper = operator_role.upper() if operator_role else None
if operator_role_upper not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足")
user = SysUser.query.get(user_id)
@ -163,10 +169,11 @@ class AuthService:
v for k, v in UserRole.__dict__.items()
if not k.startswith('__') and isinstance(v, str)
]
new_role = data['role']
new_role_raw = data['role']
new_role = new_role_raw.upper() if new_role_raw else None
if new_role not in valid_roles:
raise Exception(f"角色无效")
if operator_role == UserRole.SUPERVISOR and new_role == UserRole.SUPER_ADMIN:
if operator_role_upper == UserRole.SUPERVISOR and new_role == UserRole.SUPER_ADMIN:
raise Exception("权限不足")
user.role = new_role
@ -202,7 +209,9 @@ class AuthService:
@staticmethod
def delete_user(user_id, operator_role):
"""删除用户"""
if operator_role != UserRole.SUPER_ADMIN:
# 标准化操作者角色为全大写
operator_role_upper = operator_role.upper() if operator_role else None
if operator_role_upper != UserRole.SUPER_ADMIN:
raise Exception("权限不足:只有超级管理员可以删除用户")
user = SysUser.query.get(user_id)
@ -211,4 +220,46 @@ class AuthService:
db.session.delete(user)
db.session.commit()
return True
return True
@staticmethod
def get_user_permissions(role_code):
"""
获取指定角色的所有权限代码列表
返回格式: {
'menus': ['inbound_buy', 'system_user'],
'elements': ['inbound_buy:unit_price', ...]
}
"""
# 超级管理员返回所有权限(通配符)
from app.utils.constants import UserRole
if role_code and role_code.upper() == UserRole.SUPER_ADMIN:
# 返回通配符,表示拥有所有菜单和元素权限
return {
'menus': ['*'],
'elements': ['*']
}
# 1. 查菜单权限
menu_perms = SysRolePermission.query.filter(
func.upper(SysRolePermission.role_code) == role_code.upper(),
SysRolePermission.type == 'menu'
).all()
menu_codes = [p.target_code for p in menu_perms]
# 2. 查元素(列)权限
# 注意:这里我们只返回用户拥有的。前端逻辑是:"如果列配置了Key且用户没这个Key则隐藏"
element_perms = SysRolePermission.query.filter(
func.upper(SysRolePermission.role_code) == role_code.upper(),
SysRolePermission.type == 'element'
).all()
# 这里的 target_code 就是列的 code (如 unit_price)
# 为了防止不同页面有相同列名导致的混淆,我们之前数据库设计是做了隔离的
# 但为了前端处理方便,我们直接返回列的 code 集合
element_codes = [p.target_code for p in element_perms]
return {
'menus': menu_codes,
'elements': element_codes
}

View File

@ -6,7 +6,7 @@ from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
# from app.models.inbound.service import StockService
from sqlalchemy import or_, and_
from sqlalchemy import or_, and_, func
import traceback
import json
import io
@ -14,6 +14,7 @@ import datetime
# 需要 pip install openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from collections import defaultdict
class MaterialBaseService:
@ -114,7 +115,41 @@ class MaterialBaseService:
获取基础信息列表 (带分页和筛选)
"""
try:
query = MaterialBase.query
# 构建聚合子查询
buy_sub = db.session.query(
StockBuy.base_id,
func.sum(StockBuy.stock_quantity).label('buy_inv'),
func.sum(StockBuy.available_quantity).label('buy_avail')
).group_by(StockBuy.base_id).subquery()
semi_sub = db.session.query(
StockSemi.base_id,
func.sum(StockSemi.stock_quantity).label('semi_inv'),
func.sum(StockSemi.available_quantity).label('semi_avail')
).group_by(StockSemi.base_id).subquery()
prod_sub = db.session.query(
StockProduct.base_id,
func.sum(StockProduct.stock_quantity).label('prod_inv'),
func.sum(StockProduct.available_quantity).label('prod_avail')
).group_by(StockProduct.base_id).subquery()
# 总库存和可用数的 SQL 表达式
total_inv = func.coalesce(buy_sub.c.buy_inv, 0) + \
func.coalesce(semi_sub.c.semi_inv, 0) + \
func.coalesce(prod_sub.c.prod_inv, 0)
total_avail = func.coalesce(buy_sub.c.buy_avail, 0) + \
func.coalesce(semi_sub.c.semi_avail, 0) + \
func.coalesce(prod_sub.c.prod_avail, 0)
# 主查询,关联聚合子查询
query = db.session.query(
MaterialBase,
total_inv.label('total_inv'),
total_avail.label('total_avail')
).outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id) \
.outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id) \
.outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id)
if filters:
# 1. 关键词模糊搜索
@ -127,37 +162,47 @@ class MaterialBaseService:
))
# 2. 精确筛选
# 公司筛选
if filters.get('company'):
query = query.filter_by(company_name=filters['company'])
company = filters.get('company')
if company is not None and company != '':
query = query.filter(MaterialBase.company_name.ilike(company.strip()))
if filters.get('category'):
query = query.filter_by(category=filters['category'])
category = filters.get('category')
if category is not None and category != '':
query = query.filter(MaterialBase.category.ilike(category.strip()))
if filters.get('type'):
query = query.filter_by(material_type=filters['type'])
type_val = filters.get('type')
if type_val is not None and type_val != '':
query = query.filter(MaterialBase.material_type.ilike(type_val.strip()))
if filters.get('isEnabled') is not None:
is_active = bool(int(filters['isEnabled']))
query = query.filter_by(is_enabled=is_active)
# [修改3] 默认排序方式改为按 spec_model 排序
pagination = query.order_by(MaterialBase.spec_model.asc()).paginate(page=page, per_page=limit,
error_out=False)
# 排序处理
order_by_column = filters.get('orderByColumn', '')
is_asc = filters.get('isAsc', None)
if order_by_column == 'inventoryCount':
if is_asc == 'asc':
query = query.order_by(total_inv.asc())
else:
query = query.order_by(total_inv.desc())
elif order_by_column == 'availableCount':
if is_asc == 'asc':
query = query.order_by(total_avail.asc())
else:
query = query.order_by(total_avail.desc())
else:
# 默认排序:优先按总库存数降序,当库存相同时,再按规格型号升序
query = query.order_by(total_inv.desc(), MaterialBase.spec_model.asc())
# 分页
pagination = query.paginate(page=page, per_page=limit, error_out=False)
items_list = []
for item in pagination.items:
for item, inv, avail in pagination.items:
item_dict = item.to_dict()
# 聚合库存
buy_inv, buy_avail = MaterialBaseService._get_stock_counts(item.stock_buys)
semi_inv, semi_avail = MaterialBaseService._get_stock_counts(item.stock_semis)
prod_inv, prod_avail = MaterialBaseService._get_stock_counts(item.stock_products)
serv_inv, serv_avail = MaterialBaseService._get_stock_counts(getattr(item, 'stock_services', []))
item_dict['inventoryCount'] = buy_inv + semi_inv + prod_inv + serv_inv
item_dict['availableCount'] = buy_avail + semi_avail + prod_avail + serv_avail
item_dict['inventoryCount'] = float(inv) if inv is not None else 0.0
item_dict['availableCount'] = float(avail) if avail is not None else 0.0
items_list.append(item_dict)
return {"total": pagination.total, "items": items_list}
@ -217,7 +262,6 @@ class MaterialBaseService:
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
new_material = MaterialBase(
# [修改] 移除了 'IRIS' 默认值
company_name=data.get('companyName'),
name=data['name'],
common_name=data.get('commonName'),
@ -307,13 +351,13 @@ class MaterialBaseService:
raise e
# ==============================================================================
# [核心修改] 统一资产统计导出
# [核心修改] 统一资产统计导出(增加最高单价计算逻辑)
# ==============================================================================
@staticmethod
def export_excel(filters=None):
def export_excel(filters=None, user_permissions=None):
"""
全口径资产统计报表:
逻辑:先查库存表 (Buy, Semi, Product),关联基础信息,只导出实际存在的库存记录。
根据基础信息列表和库存表,计算出每个物料的最高历史单价并进行导出
"""
try:
# 1. 构造基础信息的筛选条件 (用于过滤库存)
@ -327,12 +371,15 @@ class MaterialBaseService:
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw)
))
if filters.get('company'):
filter_conditions.append(MaterialBase.company_name == filters['company'])
if filters.get('category'):
filter_conditions.append(MaterialBase.category == filters['category'])
if filters.get('type'):
filter_conditions.append(MaterialBase.material_type == filters['type'])
company = filters.get('company')
if company is not None and company != '':
filter_conditions.append(MaterialBase.company_name.ilike(company.strip()))
category = filters.get('category')
if category is not None and category != '':
filter_conditions.append(MaterialBase.category.ilike(category.strip()))
type_val = filters.get('type')
if type_val is not None and type_val != '':
filter_conditions.append(MaterialBase.material_type.ilike(type_val.strip()))
if filters.get('isEnabled') is not None:
is_active = bool(int(filters['isEnabled']))
filter_conditions.append(MaterialBase.is_enabled == is_active)
@ -362,21 +409,56 @@ class MaterialBaseService:
query_product = query_product.filter(cond)
list_product = query_product.all()
# ====================================================
# [核心新增] 预先计算每个 base_id 的全局最高历史单价
# 优先级:采购件 > 半成品 > 成品
# ====================================================
buy_max_prices = {}
for stock, base in list_buy:
price = float(stock.pre_tax_unit_price or 0)
if price > buy_max_prices.get(base.id, 0):
buy_max_prices[base.id] = price
semi_max_prices = {}
for stock, base in list_semi:
# 半成品的单价直接取自 manual_cost 字段(单件总成本)
price = float(stock.manual_cost or 0)
if price > semi_max_prices.get(base.id, 0):
semi_max_prices[base.id] = price
product_max_prices = {}
for stock, base in list_product:
# 成品的单价直接取自 manual_cost 字段(单件总成本)
price = float(stock.manual_cost or 0)
if price > product_max_prices.get(base.id, 0):
product_max_prices[base.id] = price
# 构造获取某个物料最高价的闭包函数
def get_highest_price(base_id):
if base_id in buy_max_prices and buy_max_prices[base_id] > 0:
return buy_max_prices[base_id]
if base_id in semi_max_prices and semi_max_prices[base_id] > 0:
return semi_max_prices[base_id]
if base_id in product_max_prices and product_max_prices[base_id] > 0:
return product_max_prices[base_id]
return 0.0
# 3. 数据整合
all_rows = []
# 处理采购件
for stock, base in list_buy:
# 价格计算
unit_price = float(stock.unit_price or 0)
tax_rate = float(stock.tax_rate or 0)
price_incl = unit_price * (1 + tax_rate / 100.0)
qty = float(stock.stock_quantity or 0)
# 使用该物料的全局最高单价作为不含税单价
highest_excl_price = get_highest_price(base.id)
tax_rate = float(stock.tax_rate or 0)
# 计算不含税总价 = 数量 * 不含税单价
total_val_excl = qty * unit_price
# 计算含税总价 = 数量 * 含税单价
total_val_incl = qty * price_incl
# 计算含税单价和总额
highest_incl_price = highest_excl_price * (1 + tax_rate / 100.0)
total_val_excl = qty * highest_excl_price
total_val_incl = qty * highest_incl_price
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
@ -389,22 +471,21 @@ class MaterialBaseService:
"date": stock.in_date,
"qty": qty,
"avail": float(stock.available_quantity or 0),
"price_excl": unit_price,
"total_val_excl": total_val_excl, # [新增]
"price_excl": highest_excl_price,
"total_val_excl": total_val_excl,
"tax": tax_rate,
"price_incl": price_incl,
"price_incl": highest_incl_price,
"total_val": total_val_incl
})
# 处理半成品
for stock, base in list_semi:
cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0)
qty = float(stock.stock_quantity or 0)
# 半成品的单价直接取自 manual_cost 字段(单件总成本)
unit_cost = float(stock.manual_cost or 0)
# 半成品不含税总价 = 数量 * 成本
total_val_excl = qty * cost
# 含税总价同上 (税率0)
total_val_incl = qty * cost
total_val_excl = qty * unit_cost
total_val_incl = qty * unit_cost # 半成品无税
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
@ -417,20 +498,21 @@ class MaterialBaseService:
"date": stock.production_date,
"qty": qty,
"avail": float(stock.available_quantity or 0),
"price_excl": cost,
"total_val_excl": total_val_excl, # [新增]
"price_excl": unit_cost,
"total_val_excl": total_val_excl,
"tax": 0.0,
"price_incl": cost,
"price_incl": unit_cost,
"total_val": total_val_incl
})
# 处理成品
for stock, base in list_product:
cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0)
qty = float(stock.stock_quantity or 0)
# 成品的单价直接取自 manual_cost 字段(单件总成本)
unit_cost = float(stock.manual_cost or 0)
total_val_excl = qty * cost
total_val_incl = qty * cost
total_val_excl = qty * unit_cost
total_val_incl = qty * unit_cost
ident = stock.serial_number or stock.barcode or stock.sku
@ -443,10 +525,10 @@ class MaterialBaseService:
"date": stock.production_date,
"qty": qty,
"avail": float(stock.available_quantity or 0),
"price_excl": cost,
"total_val_excl": total_val_excl, # [新增]
"price_excl": unit_cost,
"total_val_excl": total_val_excl,
"tax": 0.0,
"price_incl": cost,
"price_incl": unit_cost,
"total_val": total_val_incl
})
@ -463,7 +545,7 @@ class MaterialBaseService:
ws = wb.active
ws.title = "库存统计"
# 表头 [修改] 增加 "资产总额 (不含税)"
# 表头 (严格对应你的图 5)
headers = [
"所属公司", "资产名称", "规格型号", "物料类型",
"类别一级", "类别二级", "类别三级", "类别四级", "类别五级",
@ -475,6 +557,36 @@ class MaterialBaseService:
]
ws.append(headers)
# 确定各字段在表头中的列索引
col_idx = {}
for idx, header in enumerate(headers):
if header == "所属公司":
col_idx['companyName'] = idx
elif header == "资产名称":
col_idx['name'] = idx
elif header == "规格型号":
col_idx['spec'] = idx
elif header == "物料类型":
col_idx['type'] = idx
elif header in ("类别一级", "类别二级", "类别三级", "类别四级", "类别五级"):
col_idx.setdefault('category_cols', []).append(idx)
elif header == "计量单位":
col_idx['unit'] = idx
elif header == "库存数量":
col_idx['inventoryCount'] = idx
elif header == "可用数量":
col_idx['availableCount'] = idx
elif header == "单价/成本 (不含税)":
col_idx['price_excl'] = idx
elif header == "资产总额 (不含税)":
col_idx['total_val_excl'] = idx
elif header == "税率 (%)":
col_idx['tax'] = idx
elif header == "单价/成本 (含税)":
col_idx['price_incl'] = idx
elif header == "资产总额 (含税)":
col_idx['total_val'] = idx
# 样式
header_fill = PatternFill(start_color="D7E4BC", end_color="D7E4BC", fill_type="solid")
border_style = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'),
@ -486,7 +598,19 @@ class MaterialBaseService:
cell.fill = header_fill
cell.border = border_style
# 写入数据
# 字段到权限码的映射
field_to_perm = {
'companyName': 'material_list:companyName',
'name': 'material_list:name',
'spec': 'material_list:spec',
'type': 'material_list:type',
'unit': 'material_list:unit',
'category': 'material_list:category',
'inventoryCount': 'material_list:inventoryCount',
'availableCount': 'material_list:availableCount'
}
# 写入数据,并脱敏
for r in all_rows:
base = r['base']
# 类别拆分
@ -512,11 +636,56 @@ class MaterialBaseService:
r['qty'],
r['avail'],
r['price_excl'],
r['total_val_excl'], # [新增] 对应列
r['total_val_excl'],
r['tax'],
r['price_incl'],
r['total_val']
]
# 根据用户权限脱敏
if user_permissions is not None:
for field, perm_code in field_to_perm.items():
if perm_code not in user_permissions:
if field == 'category':
for cat_idx in col_idx.get('category_cols', []):
row_val[cat_idx] = ''
elif field in col_idx:
row_val[col_idx[field]] = ''
# 联动脱敏:根据数据来源,校验对应模块的价格/成本权限
if user_permissions is not None:
# 超级管理员拥有所有权限,跳过价格脱敏
if 'material_list:*' in user_permissions:
# 拥有通配符权限,不隐藏价格列
pass
else:
has_price_perm = True
row_type = r['type_name']
# 根据数据来源检查对应模块的权限
if row_type == '采购件':
# 校验采购模块的价格权限
has_price_perm = any(p in user_permissions for p in
['inbound_buy:postTaxUnitPrice', 'inbound_buy:preTaxUnitPrice',
'inbound_buy:totalAmount'])
elif row_type == '半成品':
# 校验半成品模块的成本权限
has_price_perm = any(p in user_permissions for p in
['inbound_semi:rawMaterialCost', 'inbound_semi:manualCost'])
elif row_type == '成品':
# 校验成品模块的成本权限
has_price_perm = any(p in user_permissions for p in
['inbound_product:rawMaterialCost', 'inbound_product:manualCost'])
else:
# 未知类型,默认隐藏价格列
has_price_perm = False
# 如果没有对应模块的价格查看权限则清空涉密的5个列
if not has_price_perm:
for p_col in ['price_excl', 'total_val_excl', 'tax', 'price_incl', 'total_val']:
if p_col in col_idx:
row_val[col_idx[p_col]] = ''
ws.append(row_val)
# 列宽调整
@ -535,4 +704,4 @@ class MaterialBaseService:
except Exception as e:
traceback.print_exc()
raise e
raise e

View File

@ -117,7 +117,13 @@ class BuyInboundService:
in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0)
tax_rate = float(data.get('tax_rate') or 0) # [新增]
tax_rate = float(data.get('tax_rate') or 0)
# 计算税后单价
post_tax_price = float(data.get('post_tax_unit_price') or 0)
if post_tax_price == 0 and u_price > 0:
tax_multiplier = 1 + (tax_rate / 100)
post_tax_price = u_price * tax_multiplier
try:
seq_sql = text("SELECT nextval('global_print_seq')")
@ -137,8 +143,9 @@ class BuyInboundService:
warehouse_location=data.get('warehouse_location'),
# 价格信息
unit_price=u_price,
tax_rate=tax_rate, # [新增]
pre_tax_unit_price=u_price,
post_tax_unit_price=post_tax_price,
tax_rate=tax_rate,
total_price=in_qty * u_price,
currency=data.get('currency', 'CNY'),
exchange_rate=data.get('exchange_rate', 1.0),
@ -182,8 +189,22 @@ class BuyInboundService:
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo'])
if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report'])
# [新增] 更新税率
if 'tax_rate' in data: stock.tax_rate = float(data['tax_rate'])
# 更新税率
if 'tax_rate' in data:
stock.tax_rate = float(data['tax_rate'])
# 更新税前单价
if 'unit_price' in data:
stock.pre_tax_unit_price = float(data['unit_price'])
# 更新税后单价
if 'post_tax_unit_price' in data:
stock.post_tax_unit_price = float(data['post_tax_unit_price'])
else:
# 如果税后单价没有提供,根据税前单价和税率计算
if 'unit_price' in data or 'tax_rate' in data:
tax_multiplier = 1 + (float(data.get('tax_rate', stock.tax_rate or 0)) / 100)
stock.post_tax_unit_price = float(stock.pre_tax_unit_price) * tax_multiplier
if 'in_quantity' in data:
diff = float(data['in_quantity']) - float(stock.in_quantity)
@ -192,9 +213,8 @@ class BuyInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
if 'unit_price' in data: stock.unit_price = float(data['unit_price'])
stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
# 重新计算总价
stock.total_price = float(stock.in_quantity) * float(stock.pre_tax_unit_price)
db.session.commit()
return stock
except Exception as e:
@ -322,4 +342,4 @@ class BuyInboundService:
@staticmethod
def get_history_locations(base_id):
return [r[0] for r in
db.session.query(StockBuy.warehouse_location).filter(StockBuy.base_id == base_id).distinct().all()]
db.session.query(StockBuy.warehouse_location).filter(StockBuy.base_id == base_id).distinct().all()]

View File

@ -10,9 +10,6 @@ import json
class ProductInboundService:
# ============================================================
# 0. 辅助:唯一性校验
# ============================================================
@staticmethod
def _check_unique(serial_number, exclude_id=None):
from app.models.inbound.product import StockProduct
@ -25,11 +22,8 @@ class ProductInboundService:
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
def search_base_material(keyword, page=1, limit=50):
try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
@ -38,15 +32,16 @@ class ProductInboundService:
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) # [新增]
MaterialBase.company_name.ilike(kw)
)
)
query = query.order_by(MaterialBase.id.desc()).limit(20)
query = query.order_by(MaterialBase.id.desc())
pagination = query.paginate(page=page, per_page=limit, error_out=False)
results = []
for item in query.all():
for item in pagination.items:
results.append({
'id': item.id,
'company_name': item.company_name, # [新增]
'company_name': item.company_name,
'name': item.name,
'spec': item.spec_model,
'category': item.category,
@ -54,14 +49,16 @@ class ProductInboundService:
'type': item.material_type,
'status': '启用'
})
return results
return {
"items": results,
"total": pagination.total,
"page": page,
"has_next": pagination.has_next
}
except Exception:
traceback.print_exc()
return []
return {"items": [], "total": 0, "page": 1, "has_next": False}
# ============================================================
# 1.5 BOM 搜索逻辑
# ============================================================
@staticmethod
def search_bom_options(keyword):
from app.models.bom import BomTable
@ -98,9 +95,6 @@ class ProductInboundService:
traceback.print_exc()
return []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod
def handle_inbound(data):
from app.models.inbound.product import StockProduct
@ -132,6 +126,11 @@ class ProductInboundService:
in_date_val = current_time
in_qty = float(data.get('in_quantity') or 0)
raw_cost = float(data.get('raw_material_cost') or 0)
manual_cost = 0.0 # 字段已弃用,保持向后兼容
unit_total_cost = float(data.get('unit_total_cost') or raw_cost or 0)
total_price = unit_total_cost * in_qty
p_start = data.get('production_start_time', '')
p_end = data.get('production_end_time', '')
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
@ -170,8 +169,8 @@ class ProductInboundService:
work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'),
production_time_range=time_range,
raw_material_cost=float(data.get('raw_material_cost') or 0),
manual_cost=float(data.get('manual_cost') or 0),
raw_material_cost=raw_cost,
manual_cost=unit_total_cost,
quality_status=data.get('quality_status', '合格'),
product_photo=json.dumps(photo_list),
quality_report_link=json.dumps(quality_list),
@ -188,9 +187,6 @@ class ProductInboundService:
db.session.rollback()
raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
from app.models.inbound.product import StockProduct
@ -225,7 +221,7 @@ class ProductInboundService:
if 'sale_price' in data: stock.sale_price = float(data['sale_price'])
if 'raw_material_cost' in data: stock.raw_material_cost = float(data['raw_material_cost'])
if 'manual_cost' in data: stock.manual_cost = float(data['manual_cost'])
if 'unit_total_cost' in data: stock.manual_cost = float(data['unit_total_cost']) # 映射到 manual_cost 物理字段
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
@ -234,6 +230,7 @@ class ProductInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
if 'production_start_time' in data or 'production_end_time' in data:
old_range = stock.production_time_range or " ~ "
parts = old_range.split(' ~ ')
@ -249,9 +246,6 @@ class ProductInboundService:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod
def delete_inbound(stock_id):
from app.models.inbound.product import StockProduct
@ -265,9 +259,6 @@ class ProductInboundService:
db.session.rollback()
raise e
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod
def get_outbound_history(stock_id):
try:
@ -278,9 +269,6 @@ class ProductInboundService:
except:
return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.product import StockProduct
@ -291,7 +279,7 @@ class ProductInboundService:
query = query.filter(or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw), # [新增]
MaterialBase.company_name.ilike(kw),
StockProduct.serial_number.ilike(kw),
StockProduct.work_order_code.ilike(kw),
StockProduct.order_id.ilike(kw),
@ -302,7 +290,6 @@ class ProductInboundService:
if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增]
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
@ -330,7 +317,9 @@ class ProductInboundService:
items = []
for item in current_items:
items.append(item.to_dict()) # 使用 Model to_dict
item_dict = item.to_dict()
item_dict['unit_total_cost'] = float(item.manual_cost or 0)
items.append(item_dict)
return {"total": pagination.total, "items": items}
except:
traceback.print_exc()
@ -358,29 +347,20 @@ class ProductInboundService:
except Exception:
return []
# ============================================================
# 7. 获取筛选项
# ============================================================
@staticmethod
def get_filter_options():
try:
from app.models.base import MaterialBase
# 类别
categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all()
categories = db.session.query(MaterialBase.category).filter(MaterialBase.category != None,
MaterialBase.category != '').distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 类型
types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all()
types = db.session.query(MaterialBase.material_type).filter(MaterialBase.material_type != None,
MaterialBase.material_type != '').distinct().all()
sorted_types = sorted([r[0] for r in types])
# [新增] 公司
companies = db.session.query(MaterialBase.company_name) \
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
.distinct().all()
companies = db.session.query(MaterialBase.company_name).filter(MaterialBase.company_name != None,
MaterialBase.company_name != '').distinct().all()
sorted_companies = sorted([r[0] for r in companies])
return {
@ -391,4 +371,71 @@ class ProductInboundService:
except Exception:
import traceback
traceback.print_exc()
return {"categories": [], "types": [], "companies": []}
return {"categories": [], "types": [], "companies": []}
@staticmethod
def get_history_managers(keyword=None):
from app.models.inbound.product import StockProduct
try:
query = db.session.query(StockProduct.production_manager).filter(
StockProduct.production_manager.isnot(None),
StockProduct.production_manager != ''
)
if keyword:
query = query.filter(StockProduct.production_manager.ilike(f'%{keyword}%'))
records = query.distinct().all()
return [r[0] for r in records if r[0]]
except Exception:
traceback.print_exc()
return []
# ============================================================
# 9. BOM 原材料成本自动核算 (新增)
# ============================================================
@staticmethod
def calculate_bom_cost(bom_no, bom_version):
"""
根据 BOM 编号和版本计算原材料总成本
遍历 BOM 子件,使用原生 SQL 查物理表 bom_table取每个子件在采购、半成品、成品三个表中的最高单价乘以用量后累加
"""
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy import func, text
try:
# 使用原生 SQL 精准查询 bom_table避免模型映射错误
sql = text("""
SELECT child_id, dosage
FROM bom_table
WHERE bom_no = :bom_no AND version = :version
""")
bom_lines = db.session.execute(sql, {'bom_no': bom_no, 'version': bom_version}).fetchall()
total_cost = 0.0
for line in bom_lines:
component_base_id = line[0] # child_id
usage_qty = float(line[1] or 1.0) # dosage
# 1. 查采购表最高价 (不含税)
buy_price = db.session.query(func.max(StockBuy.pre_tax_unit_price)).filter(
StockBuy.base_id == component_base_id
).scalar() or 0.0
# 2. 查半成品表最高价 (单件成本映射存在 manual_cost 里了)
semi_price = db.session.query(func.max(StockSemi.manual_cost)).filter(
StockSemi.base_id == component_base_id
).scalar() or 0.0
# 3. 查成品表最高价 (同样存储在 manual_cost 字段里)
product_price = db.session.query(func.max(StockProduct.manual_cost)).filter(
StockProduct.base_id == component_base_id
).scalar() or 0.0
# 4. 取三个表中的最大值,乘以用量 (dosage)
max_price = max(float(buy_price), float(semi_price), float(product_price))
total_cost += max_price * usage_qty
return round(total_cost, 2)
except Exception as e:
traceback.print_exc()
raise e

View File

@ -10,9 +10,6 @@ import json
class SemiInboundService:
# ============================================================
# 0. 辅助:唯一性校验
# ============================================================
@staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
from app.models.inbound.semi import StockSemi
@ -35,11 +32,8 @@ class SemiInboundService:
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
def search_base_material(keyword, page=1, limit=50):
try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
@ -48,15 +42,16 @@ class SemiInboundService:
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) # [新增] 支持搜公司
MaterialBase.company_name.ilike(kw)
)
)
query = query.order_by(MaterialBase.id.desc()).limit(20)
query = query.order_by(MaterialBase.id.desc())
pagination = query.paginate(page=page, per_page=limit, error_out=False)
results = []
for item in query.all():
for item in pagination.items:
results.append({
'id': item.id,
'company_name': item.company_name, # [新增]
'company_name': item.company_name,
'name': item.name,
'spec': item.spec_model,
'category': item.category,
@ -64,14 +59,11 @@ class SemiInboundService:
'type': item.material_type,
'status': '启用'
})
return results
return {"items": results, "total": pagination.total, "page": page, "has_next": pagination.has_next}
except Exception as e:
traceback.print_exc()
return []
return {"items": [], "total": 0, "page": 1, "has_next": False}
# ============================================================
# 1.5 BOM 搜索逻辑
# ============================================================
@staticmethod
def search_bom_options(keyword):
from app.models.bom import BomTable
@ -108,9 +100,6 @@ class SemiInboundService:
traceback.print_exc()
return []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod
def handle_inbound(data):
from app.models.inbound.semi import StockSemi
@ -167,9 +156,9 @@ class SemiInboundService:
in_qty = float(data.get('in_quantity') or 0)
raw_cost = float(data.get('raw_material_cost') or 0)
manual_cost = float(data.get('manual_cost') or 0)
unit_total_cost = raw_cost + manual_cost
total_value = unit_total_cost * in_qty
# 【重要修改】:把前端的 unit_total_cost单件成本存入原数据库的 manual_cost 字段中
unit_cost = float(data.get('unit_total_cost') or raw_cost)
total_value = unit_cost * in_qty
next_global_id = 0
try:
@ -215,7 +204,7 @@ class SemiInboundService:
production_end_time=p_end,
production_time_range=time_range_str,
raw_material_cost=raw_cost,
manual_cost=manual_cost,
manual_cost=unit_cost, # 映射到 manual_cost 物理字段
total_price=total_value,
arrival_photo=json.dumps(arrival_list),
quality_report_link=json.dumps(quality_report_list),
@ -231,9 +220,6 @@ class SemiInboundService:
traceback.print_exc()
raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
from app.models.inbound.semi import StockSemi
@ -307,7 +293,6 @@ class SemiInboundService:
stock.production_time_range = raw_range
qty_changed = False
cost_changed = False
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
@ -316,15 +301,16 @@ class SemiInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
qty_changed = True
if 'raw_material_cost' in data:
stock.raw_material_cost = float(data['raw_material_cost'])
cost_changed = True
if 'manual_cost' in data:
stock.manual_cost = float(data['manual_cost'])
cost_changed = True
if cost_changed or qty_changed:
unit_total = float(stock.raw_material_cost) + float(stock.manual_cost)
stock.total_price = float(stock.in_quantity) * unit_total
if 'unit_total_cost' in data:
stock.manual_cost = float(data['unit_total_cost']) # 映射到 manual_cost 物理字段
if 'unit_total_cost' in data or qty_changed:
qty = float(stock.in_quantity or 1)
# 使用存入 manual_cost 的单价计算总价
stock.total_price = float(stock.manual_cost or 0) * qty
db.session.commit()
return stock
@ -332,9 +318,6 @@ class SemiInboundService:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod
def delete_inbound(stock_id):
from app.models.inbound.semi import StockSemi
@ -349,9 +332,6 @@ class SemiInboundService:
db.session.rollback()
raise e
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod
def get_outbound_history(stock_id):
try:
@ -362,9 +342,6 @@ class SemiInboundService:
except:
return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.semi import StockSemi
@ -376,7 +353,7 @@ class SemiInboundService:
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw), # [新增]
MaterialBase.company_name.ilike(kw),
StockSemi.batch_number.ilike(kw),
StockSemi.serial_number.ilike(kw),
StockSemi.sku.ilike(kw),
@ -389,7 +366,6 @@ class SemiInboundService:
if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增] 公司筛选
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
@ -406,18 +382,12 @@ class SemiInboundService:
)
pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False)
current_items = pagination.items
def parse_img(json_str):
if not json_str: return []
try:
return json.loads(json_str) if json_str.startswith('[') else [json_str]
except:
return []
items = []
for item in current_items:
items.append(item.to_dict()) # 直接使用 Model 的 to_dict (已包含 company_name)
for item in pagination.items:
# 把 manual_cost 伪装成 unit_total_cost 返回给前端
item_dict = item.to_dict()
item_dict['unit_total_cost'] = float(item.manual_cost or 0)
items.append(item_dict)
return {"total": pagination.total, "items": items}
except Exception as e:
print(f"List Error: {e}")
@ -446,29 +416,18 @@ class SemiInboundService:
except Exception:
return []
# ============================================================
# 7. 获取筛选项 (排序)
# ============================================================
@staticmethod
def get_filter_options():
try:
from app.models.base import MaterialBase
# 类别
categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all()
categories = db.session.query(MaterialBase.category).filter(MaterialBase.category != None,
MaterialBase.category != '').distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 类型
types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all()
types = db.session.query(MaterialBase.material_type).filter(MaterialBase.material_type != None,
MaterialBase.material_type != '').distinct().all()
sorted_types = sorted([r[0] for r in types])
# [新增] 公司
companies = db.session.query(MaterialBase.company_name) \
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
.distinct().all()
companies = db.session.query(MaterialBase.company_name).filter(MaterialBase.company_name != None,
MaterialBase.company_name != '').distinct().all()
sorted_companies = sorted([r[0] for r in companies])
return {
@ -477,6 +436,69 @@ class SemiInboundService:
"companies": sorted_companies
}
except Exception:
import traceback
traceback.print_exc()
return {"categories": [], "types": [], "companies": []}
return {"categories": [], "types": [], "companies": []}
@staticmethod
def get_history_managers(keyword=None):
from app.models.inbound.semi import StockSemi
try:
query = db.session.query(StockSemi.production_manager).filter(
StockSemi.production_manager.isnot(None),
StockSemi.production_manager != ''
)
if keyword:
query = query.filter(StockSemi.production_manager.ilike(f'%{keyword}%'))
records = query.distinct().all()
return [r[0] for r in records if r[0]]
except Exception:
traceback.print_exc()
return []
@staticmethod
def calculate_bom_cost(bom_no, bom_version):
"""
根据 BOM 编号和版本计算原材料总成本
遍历 BOM 子件,使用原生 SQL 查物理表 bom_table取每个子件在采购、半成品、成品三个表中的最高单价乘以用量后累加
"""
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy import func, text
try:
# 使用原生 SQL 精准查询 bom_table避免模型映射错误
sql = text("""
SELECT child_id, dosage
FROM bom_table
WHERE bom_no = :bom_no AND version = :version
""")
bom_lines = db.session.execute(sql, {'bom_no': bom_no, 'version': bom_version}).fetchall()
total_cost = 0.0
for line in bom_lines:
component_base_id = line[0] # child_id
usage_qty = float(line[1] or 1.0) # dosage
# 1. 查采购表最高价 (不含税)
buy_price = db.session.query(func.max(StockBuy.pre_tax_unit_price)).filter(
StockBuy.base_id == component_base_id
).scalar() or 0.0
# 2. 查半成品表最高价 (单件成本映射存在 manual_cost 里了)
semi_price = db.session.query(func.max(StockSemi.manual_cost)).filter(
StockSemi.base_id == component_base_id
).scalar() or 0.0
# 3. 查成品表最高价 (同样存储在 manual_cost 字段里)
product_price = db.session.query(func.max(StockProduct.manual_cost)).filter(
StockProduct.base_id == component_base_id
).scalar() or 0.0
# 4. 取三个表中的最大值,乘以用量 (dosage)
max_price = max(float(buy_price), float(semi_price), float(product_price))
total_cost += max_price * usage_qty
return round(total_cost, 2)
except Exception as e:
traceback.print_exc()
raise e

View File

@ -48,7 +48,7 @@ class OutboundService:
if table_type == 'stock_product':
return float(item.sale_price) if item.sale_price else 0
elif table_type == 'stock_buy':
return float(item.unit_price) if item.unit_price else 0
return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0
return 0
prod = StockProduct.query.filter(

View File

@ -0,0 +1,149 @@
from app.models.system import SysMenu, SysElement, SysRolePermission
from app.extensions import db
from sqlalchemy.exc import SQLAlchemyError
class PermissionService:
@staticmethod
def get_permission_tree():
"""
获取完整的权限树(菜单嵌套菜单 + 菜单包含元素)
供前端权限配置页面展示
"""
# 1. 获取所有菜单 (按 parent_id 和 sort_order 排序,保证父子处理顺序)
menus = SysMenu.query.order_by(SysMenu.parent_id, SysMenu.sort_order).all()
# 2. 获取所有元素
elements = SysElement.query.all()
# --- 核心逻辑:构建树形结构 ---
# 3. 创建一个 lookup 字典,方便通过 ID 查找菜单节点
# 同时将 SQLAlchemy 对象转为字典,方便后续操作
menu_map = {}
for m in menus:
m_dict = m.to_dict()
m_dict['children'] = [] # 初始化 children
menu_map[m.id] = m_dict
# 4. 创建 code 到 id 的映射,用于把 element 挂载到 menu 上
# 因为 SysElement 关联的是 menu_code而不是 menu_id
code_to_id = {m.code: m.id for m in menus}
# 5. 将元素 (Elements) 挂载到对应的菜单 (Menu) 下
for el in elements:
# 找到该元素所属菜单的 ID
parent_menu_id = code_to_id.get(el.menu_code)
if parent_menu_id and parent_menu_id in menu_map:
el_dict = el.to_dict()
# 标记类型为 element前端 transformData 需要用到
el_dict['type'] = 'element'
menu_map[parent_menu_id]['children'].append(el_dict)
# 6. 将子菜单挂载到父菜单下,并构建最终的树
tree_data = []
for m in menus:
current_node = menu_map[m.id]
if m.parent_id == 0 or m.parent_id is None:
# 如果是顶级菜单,直接放入结果集
tree_data.append(current_node)
else:
# 如果是子菜单,找到它的父级,把它塞进父级的 children 里
if m.parent_id in menu_map:
menu_map[m.parent_id]['children'].append(current_node)
else:
# 如果找不到父级(比如父级被删了),为了防止数据丢失,暂时作为顶级显示
tree_data.append(current_node)
return tree_data
@staticmethod
def get_role_permissions(role_code):
"""获取指定角色拥有的所有权限Code"""
try:
# === 新增逻辑:超级管理员上帝模式 ===
if role_code == 'SUPER_ADMIN':
# 直接获取所有菜单和元素,无视配置表
all_menus = [m.code for m in SysMenu.query.all()]
all_elements = [e.code for e in SysElement.query.all()]
return {
'menus': all_menus,
'elements': all_elements
}
# =================================
perms = SysRolePermission.query.filter_by(role_code=role_code).all()
menu_codes = []
element_codes = []
for p in perms:
# 这里假设你的数据库存的是 target_code
if p.type == 'menu':
menu_codes.append(p.target_code)
else:
element_codes.append(p.target_code)
# 前端 handleRoleSelect 会合并这两个数组,所以分开返回没问题
return {
'menus': menu_codes,
'elements': element_codes
}
except Exception as e:
# 记录日志或处理错误
print(f"Error fetching role permissions: {e}")
return {'menus': [], 'elements': []}
@staticmethod
def assign_permissions(role_code, permissions):
"""
保存角色的权限
permissions: 前端传来的 list混合了 menu_code 和 element_code
"""
if not role_code:
raise ValueError("角色代码不能为空")
session = db.session
try:
# 1. 开启事务 (Flask-SQLAlchemy 自动管理,但明确逻辑更好)
# 2. 删除该角色旧的所有权限
SysRolePermission.query.filter_by(role_code=role_code).delete()
# 3. 准备新数据
if permissions:
# 3.1 去重
unique_codes = set(permissions)
# 3.2 预加载所有 Menu Code用于区分是 Menu 还是 Element
# 这一步很重要,因为 SysRolePermission 表需要 type 字段
all_menu_codes = {res[0] for res in session.query(SysMenu.code).all()}
new_records = []
for code in unique_codes:
if not code: continue
# 判断类型:如果 code 存在于菜单表中,就是 menu否则就是 element
p_type = 'menu' if code in all_menu_codes else 'element'
new_records.append(SysRolePermission(
role_code=role_code,
target_code=code,
type=p_type
))
# 3.3 批量插入
if new_records:
session.add_all(new_records)
# 4. 提交
session.commit()
return True
except SQLAlchemyError as e:
session.rollback()
raise e
except Exception as e:
session.rollback()
raise e

View File

@ -1,7 +1,8 @@
# app/utils/decorators.py
from functools import wraps
from flask_jwt_extended import get_jwt
from flask import jsonify
from flask_jwt_extended import get_jwt, verify_jwt_in_request
from flask import jsonify, g
import logging
def role_required(*roles):
@ -15,16 +16,74 @@ def role_required(*roles):
def decorator(*args, **kwargs):
claims = get_jwt()
user_role = claims.get('role')
user_role_upper = user_role.upper() if user_role else None
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
if user_role == 'super_admin':
if user_role_upper == 'SUPER_ADMIN':
return fn(*args, **kwargs)
if user_role not in roles:
if user_role_upper not in [r.upper() for r in roles]:
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper
return wrapper
def login_required(fn):
"""
验证 JWT 令牌是否存在且有效
"""
@wraps(fn)
def decorator(*args, **kwargs):
try:
verify_jwt_in_request()
except Exception as e:
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
return fn(*args, **kwargs)
return decorator
def permission_required(permission_code):
"""
检查当前用户是否拥有指定权限码
使用方法: @permission_required('material:base:read')
"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
# 首先验证 JWT
try:
verify_jwt_in_request()
except Exception as e:
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
claims = get_jwt()
user_role = claims.get('role')
# 超级管理员放行 (忽略大小写)
if user_role and user_role.upper() == 'SUPER_ADMIN':
return fn(*args, **kwargs)
# 根据角色查询数据库中的权限
try:
from app.services.auth_service import AuthService
perm_dict = AuthService.get_user_permissions(user_role)
except Exception as e:
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
return jsonify(msg='权限查询失败'), 403
# 合并菜单和元素权限
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if permission_code not in all_perms:
# 详细的调试日志
print(f"🔴 [权限拦截] 角色 '{user_role}' 访问被拒!需要权限码: '{permission_code}', 但该角色实际拥有: {all_perms}")
logging.warning(
f"权限检查失败: 角色={user_role}, 所需权限={permission_code}, 实际权限列表={all_perms}")
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { InfoFilled, SwitchButton, UserFilled } from '@element-plus/icons-vue'
@ -14,6 +14,13 @@ const isLoginPage = computed(() => {
return route.path === '/login'
})
// 页面加载时刷新权限
onMounted(() => {
if (userStore.token) {
userStore.refreshUserPermissions()
}
})
// --- 退出登录逻辑 Start ---
const handleLogout = () => {
ElMessageBox.confirm(
@ -82,7 +89,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本: 1.3 Beta (2.25权限管理版)
当前版本: 2.2录入测试版
</span>
</footer>
</div>
@ -197,4 +204,4 @@ const handleLogout = () => {
display: flex;
align-items: center;
}
</style>
</style>

View File

@ -11,8 +11,11 @@ export function getBomList(params?: any) {
// 获取BOM详情
export function getBomDetail(bomNo: string) {
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
const trimmed = bomNo.replace(/^\/+|\/+$/g, '');
const encoded = encodeURIComponent(trimmed);
return request({
url: `/v1/bom/detail/${bomNo}`,
url: `/v1/bom/detail/${encoded}`,
method: 'get'
})
}
@ -28,8 +31,11 @@ export function saveBom(data: any) {
// 删除BOM暂未实现预留
export function deleteBom(bomNo: string) {
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
const trimmed = bomNo.replace(/^\/+|\/+$/g, '');
const encoded = encodeURIComponent(trimmed);
return request({
url: `/v1/bom/${bomNo}`,
url: `/v1/bom/${encoded}`,
method: 'delete'
})
}

View File

@ -33,11 +33,12 @@ export function deleteProductInbound(id: number) {
})
}
export function searchMaterialBase(keyword: string) {
// 搜索基础物料 (已增加 page 参数)
export function searchMaterialBase(keyword: string, page: number = 1) {
return request({
url: '/inbound/product/search-base',
method: 'get',
params: { keyword }
params: { keyword, page }
})
}
@ -65,4 +66,13 @@ export function getFilterOptions() {
url: '/inbound/product/options',
method: 'get'
})
}
// [新增] 负责人历史记录
export function getManagerHistory(params: any) {
return request({
url: '/inbound/product/suggestions/managers',
method: 'get',
params
})
}

View File

@ -35,12 +35,12 @@ export function deleteSemiInbound(id: number) {
})
}
// 5. 搜索基础物料
export function searchMaterialBase(keyword: string) {
// 5. 搜索基础物料 (已增加 page 参数)
export function searchMaterialBase(keyword: string, page: number = 1) {
return request({
url: '/inbound/semi/search-base',
method: 'get',
params: { keyword }
params: { keyword, page }
})
}
@ -68,4 +68,13 @@ export function getFilterOptions() {
url: '/inbound/semi/options',
method: 'get'
})
}
// [新增] 生产负责人历史记录
export function getManagerHistory(params: any) {
return request({
url: '/inbound/semi/suggestions/managers',
method: 'get',
params
})
}

View File

@ -0,0 +1,26 @@
import request from '@/utils/request'
// 获取所有可用的权限树(菜单+列)
export function getAllPermissionTree() {
return request({
url: '/v1/permissions/tree',
method: 'get'
})
}
// 获取某个角色已拥有的权限列表
export function getRolePermissions(roleCode: string) {
return request({
url: '/v1/permissions/role/' + roleCode,
method: 'get'
})
}
// 保存角色的权限配置
export function saveRolePermissions(data: any) {
return request({
url: '/v1/permissions/assign',
method: 'post',
data
})
}

View File

@ -0,0 +1,134 @@
<template>
<div class="base-table">
<el-table
v-bind="$attrs"
:data="data"
border
stripe
v-loading="loading"
header-cell-class-name="table-header-gray"
>
<template v-for="col in visibleColumns" :key="col.prop">
<el-table-column
v-if="!col.slot"
v-bind="col"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '120'"
:width="col.width"
:fixed="col.fixed"
show-overflow-tooltip
/>
<el-table-column
v-else
v-bind="col"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '120'"
:width="col.width"
:fixed="col.fixed"
>
<template #default="scope">
<slot :name="col.prop" :row="scope.row" :index="scope.$index"></slot>
</template>
</el-table-column>
</template>
<template #empty>
<el-empty description="暂无数据" />
</template>
</el-table>
<div v-if="showPagination" class="pagination-container">
<el-pagination
v-bind="paginationConfig"
v-model:current-page="localPage"
v-model:page-size="localLimit"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { usePermissionStore } from '@/stores/permission'
// --- Props 定义 ---
const props = defineProps({
// 数据源
data: { type: Array, default: () => [] },
// 列配置 (核心)
columns: { type: Array as () => any[], required: true },
// 页面编码 (用于权限隔离,如果列名全局唯一可不传,但建议传)
pageCode: { type: String, default: '' },
loading: { type: Boolean, default: false },
total: { type: Number, default: 0 },
showPagination: { type: Boolean, default: true },
page: { type: Number, default: 1 },
limit: { type: Number, default: 10 }
})
const emit = defineEmits(['update:page', 'update:limit', 'pagination'])
const permStore = usePermissionStore()
// --- 核心逻辑:计算当前可见的列 ---
const visibleColumns = computed(() => {
return props.columns.filter(col => {
// 1. 获取该列在数据库中对应的 code
// 如果列配置里显式写了 code就用写的如果没有默认认为 prop 就是 code
const permissionKey = col.code || col.prop
// 2. 如果这个列不需要权限控制 (比如序号 index),可以在配置里加个 ignoreAuth: true
if (col.ignoreAuth) return true
// 3. 问 Store我有这个权限吗
// 注意:我们在 PermissionStore 里存的是全局唯一的 code
return permStore.hasColumnPermission(props.pageCode, permissionKey)
})
})
// --- 分页逻辑处理 ---
const localPage = ref(props.page)
const localLimit = ref(props.limit)
watch(() => props.page, (val) => localPage.value = val)
watch(() => props.limit, (val) => localLimit.value = val)
const handleSizeChange = (val: number) => {
emit('update:limit', val)
emit('pagination', { page: localPage.value, limit: val })
}
const handleCurrentChange = (val: number) => {
emit('update:page', val)
emit('pagination', { page: val, limit: localLimit.value })
}
const paginationConfig = {
pageSizes: [10, 20, 50, 100],
background: true
}
</script>
<style scoped>
.pagination-container {
margin-top: 15px;
display: flex;
justify-content: flex-end;
}
:deep(.table-header-gray th) {
background-color: #f8f9fb !important;
color: #606266;
font-weight: 600;
height: 45px;
}
</style>

View File

@ -195,9 +195,19 @@ const routes: Array<RouteRecordRaw> = [
meta: {
title: '账号开通',
icon: 'User',
// 子路由也建议加上权限限制
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}
},
// [新增] 权限分配页面,只有超级管理员可进
{
path: 'permission',
name: 'PermissionConfig',
component: () => import('@/views/system/PermissionConfig.vue'),
meta: {
title: '权限分配',
icon: 'Lock',
roles: ['SUPER_ADMIN']
}
}
]
},
@ -224,11 +234,10 @@ router.beforeEach((to, from, next) => {
const token = userStore.token || localStorage.getItem('token')
// [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效
// 注意Store 中存储的可能是 user.role 或者直接是 role根据你之前的 store 结构适配
const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user'
const userRole = String(rawRole).toUpperCase()
// 调试日志:如果跳转有问题,请按 F12 查看控制台输出
// 调试日志
if (to.path.includes('/system')) {
console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`)
}
@ -249,7 +258,6 @@ router.beforeEach((to, from, next) => {
// 权限检查逻辑
if (to.meta.roles && Array.isArray(to.meta.roles)) {
// [修复] to.meta.roles 里已经是大写了userRole 也转大写了,现在可以安全比对
if (to.meta.roles.includes(userRole)) {
next()
} else {

View File

@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import request from '@/utils/request'
export const usePermissionStore = defineStore('permission', () => {
// 存储我能看到的页面代码 (如 ['inbound_buy', ...])
const menuPermissions = ref<string[]>([])
// 存储我能看到的列代码 (如 ['unit_price', 'sale_price'])
const elementPermissions = ref<string[]>([])
// 初始化加载权限 (登录后调用)
const loadPermissions = async () => {
try {
const res: any = await request({
url: '/v1/auth/my-permissions',
method: 'get'
})
if (res.code === 200 && res.data) {
menuPermissions.value = res.data.menus || []
elementPermissions.value = res.data.elements || []
console.log('权限字典加载完成:', elementPermissions.value.length, '个列权限')
}
} catch (e) {
console.error('加载权限失败', e)
// 失败时清空,防止残留
menuPermissions.value = []
elementPermissions.value = []
}
}
// ★ 核心判断函数:判断当前用户是否拥有某个列/按钮的权限
// page: 页面代码 (预留字段目前全局唯一code暂不使用page隔离)
// code: 权限标识 (如 'unit_price')
const hasColumnPermission = (page: string, code: string) => {
// 1. 如果列没有配置 permissionKey说明是公开列直接放行
if (!code) return true
// 2. 检查权限池里是否有这个 code
return elementPermissions.value.includes(code)
}
return {
menuPermissions,
elementPermissions,
loadPermissions,
hasColumnPermission
}
})

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { login } from '@/api/auth'
import { getRolePermissions } from '@/api/system/permission'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
@ -7,6 +8,7 @@ export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const role = ref(localStorage.getItem('role') || '')
const username = ref(localStorage.getItem('username') || '')
const permissions = ref<string[]>(JSON.parse(localStorage.getItem('permissions') || '[]'))
// 2. Actions
// 登录逻辑
@ -33,7 +35,8 @@ export const useUserStore = defineStore('user', () => {
// 处理用户信息 (确保后端返回结构中有 user 字段)
if (data.user) {
role.value = data.user.role || 'user' // 默认给个 user 角色防止空
const rawRole = data.user.role || 'user'
role.value = rawRole.toUpperCase() // 角色统一转换为大写
username.value = data.user.username || '用户'
// 持久化存储用户信息
@ -44,6 +47,25 @@ export const useUserStore = defineStore('user', () => {
// 持久化存储 Token
localStorage.setItem('token', data.access_token)
// 登录成功后,根据角色获取权限
if (role.value) {
try {
const permRes = await getRolePermissions(role.value)
const permData = permRes.data || permRes
// 合并 menus 和 elements 两个数组
const allPerms = [
...(permData.menus || []),
...(permData.elements || [])
]
permissions.value = allPerms
localStorage.setItem('permissions', JSON.stringify(allPerms))
} catch (error) {
console.error('获取权限失败:', error)
permissions.value = []
localStorage.setItem('permissions', '[]')
}
}
return true // 返回 true 表示登录成功
}
@ -53,11 +75,36 @@ export const useUserStore = defineStore('user', () => {
token.value = ''
role.value = ''
username.value = ''
permissions.value = []
// 2. 清空 LocalStorage (硬盘)
localStorage.removeItem('token')
localStorage.removeItem('role')
localStorage.removeItem('username')
localStorage.removeItem('permissions')
}
// 刷新用户权限(不重新登录)
const refreshUserPermissions = async () => {
if (!token.value || !role.value) {
console.warn('无法刷新权限:用户未登录')
return
}
try {
const permRes = await getRolePermissions(role.value)
const permData = permRes.data || permRes
// 合并 menus 和 elements 两个数组
const allPerms = [
...(permData.menus || []),
...(permData.elements || [])
]
permissions.value = allPerms
localStorage.setItem('permissions', JSON.stringify(allPerms))
console.log('用户权限已刷新')
} catch (error) {
console.error('刷新权限失败:', error)
// 可选:保留原有权限
}
}
// 3. Getters / Helpers
@ -66,12 +113,24 @@ export const useUserStore = defineStore('user', () => {
return roles.includes(role.value)
}
// 判断当前用户是否拥有某个权限(菜单或元素)
const hasPermission = (code: string) => {
// 超级管理员拥有所有权限
if (role.value && role.value.toUpperCase() === 'SUPER_ADMIN') {
return true
}
return permissions.value.includes(code)
}
return {
token,
role,
username,
permissions,
handleLogin,
logout,
hasRole
refreshUserPermissions,
hasRole,
hasPermission
}
})

View File

@ -17,29 +17,29 @@
<el-button :icon="Search" @click="fetchBomList" />
</template>
</el-input>
<el-button type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="bomList" border style="width: 100%">
<el-table-column prop="bom_no" label="BOM编号" min-width="180" sortable />
<el-table-column prop="parent_name" label="父件名称" min-width="150" />
<el-table-column prop="parent_spec" label="父件规格" min-width="150" />
<el-table-column prop="version" label="版本" width="100" align="center">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable />
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" />
<el-table-column v-if="hasColumnPermission('version')" prop="version" label="版本" width="100" align="center">
<template #default="{ row }">
<el-tag>{{ row.version }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'">
{{ row.is_enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="child_count" label="子件数" width="80" align="center" />
<el-table-column label="操作" width="250" align="center" fixed="right">
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
@ -54,7 +54,7 @@
<el-row :gutter="20">
<el-col :span="16">
<el-form-item label="父件 (成品)" prop="parent_id">
<el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')">
<el-select
v-model="form.parent_id"
placeholder="请搜索并选择父件"
@ -79,15 +79,15 @@
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" />
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="14">
<el-form-item label="BOM 编号" required>
<el-form-item label="BOM 编号" required v-if="hasFormFieldPermission('bom_suffix')">
<el-input v-model="form.bom_suffix" placeholder="输入后缀 (如 -001)" :disabled="isEditMode">
<template #prepend v-if="form.bom_prefix">{{ form.bom_prefix }}</template>
</el-input>
@ -97,7 +97,7 @@
</el-form-item>
</el-col>
<el-col :span="10">
<el-form-item label="版本号" prop="version">
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
<el-input v-model="form.version" placeholder="如: V1.0" />
</el-form-item>
</el-col>
@ -106,7 +106,7 @@
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">子件列表</div>
<el-table :data="form.children" border style="width: 100%; margin-bottom: 15px" max-height="300">
<el-table-column label="子件物料" min-width="280">
<el-table-column label="子件物料" min-width="280" v-if="hasFormFieldPermission('child_id')">
<template #default="{ row, $index }">
<el-select
v-model="row.child_id"
@ -129,26 +129,26 @@
</template>
</el-table-column>
<el-table-column label="用量" width="140">
<el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
<template #default="{ row }">
<el-input-number v-model="row.dosage" :min="0" :precision="4" style="width: 100%" controls-position="right" />
</template>
</el-table-column>
<el-table-column label="备注" width="150">
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
<template #default="{ $index }">
<el-button type="danger" link @click="removeChild($index)"></el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px; text-align: center;">
<div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id')">
<el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
</div>
</el-form>
@ -169,6 +169,7 @@ import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock'
import { useUserStore } from '@/stores/user'
// 类型定义
interface BomItem {
@ -190,6 +191,7 @@ interface ChildRow {
remark: string
}
const userStore = useUserStore()
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
@ -199,6 +201,41 @@ const bomList = ref<BomItem[]>([])
const materialOptions = ref<MaterialBase[]>([])
const searchKeyword = ref('')
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
bom_no: 'bom_manage:bom_no',
parent_name: 'bom_manage:parent_name',
parent_spec: 'bom_manage:parent_spec',
version: 'bom_manage:version',
status: 'bom_manage:status',
child_count: 'bom_manage:child_count',
// 表单字段
parent_id: 'bom_manage:parent_id',
is_enabled: 'bom_manage:status',
bom_suffix: 'bom_manage:bom_no',
child_id: 'bom_manage:child_id',
dosage: 'bom_manage:dosage',
remark: 'bom_manage:remark',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// 检查表单字段权限
const hasFormFieldPermission = (fieldName: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[fieldName]
return code ? userStore.hasPermission(code) : false
}
const formRef = ref<FormInstance>()
const form = reactive({
bom_prefix: '', // 自动生成的父件规格前缀
@ -229,7 +266,9 @@ const fetchBomList = async () => {
try {
const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) bomList.value = res.data
} catch (error) { ElMessage.error('网络错误') }
} catch (error) {
// 错误已由全局拦截器统一处理
}
finally { loading.value = false }
}
@ -237,7 +276,9 @@ const fetchMaterialOptions = async () => {
try {
const res = await getMaterialBaseList()
if (res.code === 200) materialOptions.value = res.data
} catch (error) {}
} catch (error) {
// 错误已由全局拦截器统一处理
}
}
// 监听父件变化,自动设置前缀
@ -300,7 +341,9 @@ const loadDetail = async (bomNo: string, version: string) => {
form.bom_suffix = bomNo
}
}
} catch (e) { ElMessage.error('获取详情失败') }
} catch (e) {
// 错误已由全局拦截器统一处理
}
}
const handleDelete = (row: BomItem) => {
@ -353,7 +396,9 @@ const submitForm = async () => {
dialogVisible.value = false
fetchBomList()
} else { ElMessage.error(res.msg || '保存失败') }
} catch (e) { ElMessage.error('网络错误') }
} catch (e) {
// 错误已由全局拦截器统一处理
}
finally { saving.value = false }
})
}
@ -371,4 +416,4 @@ onMounted(() => {
.option-row { display: flex; justify-content: space-between; width: 100%; }
.option-name { font-weight: bold; color: #303133; }
.option-spec { font-size: 12px; color: #909399; margin-left: 15px; }
</style>
</style>

View File

@ -52,11 +52,13 @@
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessageBox } from 'element-plus' // 引入 ElMessageBox
import { usePermissionStore } from '@/stores/permission' // [新增] 引入权限Store
import { ElMessageBox } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
const router = useRouter()
const userStore = useUserStore()
const permissionStore = usePermissionStore() // [新增]
const loading = ref(false)
const loginFormRef = ref()
@ -74,23 +76,25 @@ const onLogin = async () => {
if (valid) {
loading.value = true
try {
// 执行登录请求
// 1. 执行登录请求
const success = await userStore.handleLogin(loginForm)
if (success) {
// 成功:跳转
// [新增] 2. 登录成功后,立即拉取当前用户的权限字典
// 这样进入 Dashboard 时,所有按钮/列的显示状态就已经确定了
await permissionStore.loadPermissions()
// 3. 跳转
router.push('/dashboard')
} else {
// 失败(业务逻辑拒绝,如账号密码错):弹出模态框
// 失败(业务逻辑拒绝):弹出模态框
showLoginFailAlert('用户名或密码错误')
}
} catch (error: any) {
// 失败(系统错误,如网络断开/500报错):弹出模态框
// 优先取后端的报错信息,没有则显示默认
// 失败(系统错误):弹出模态框
const msg = error.response?.data?.msg || error.message || '登录遇到未知错误'
showLoginFailAlert(msg)
} finally {
// 停止转圈,让用户可以看清弹窗
loading.value = false
}
}
@ -103,8 +107,6 @@ const showLoginFailAlert = (msg: string) => {
confirmButtonText: '确定',
type: 'error',
callback: () => {
// 点击确定后,清空密码框,让用户重试
// 页面绝对不会刷新,光标还在
loginForm.password = ''
}
})

View File

@ -71,7 +71,7 @@
<el-icon style="margin-right: 5px"><Download /></el-icon>导出库存统计
</el-button>
<el-button type="primary" @click="handleAdd" style="margin-right: 10px">
<el-button v-if="userStore.hasPermission('material_list:operation')" type="primary" @click="handleAdd" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button>
@ -98,18 +98,18 @@
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置
</div>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-model="columns.name.visible" label="名称" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" />
<el-checkbox v-model="columns.category.visible" label="类别" />
<el-checkbox v-model="columns.type.visible" label="类型" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-model="columns.unit.visible" label="单位" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" />
<el-checkbox v-model="columns.available.visible" label="可用数" />
<el-checkbox v-model="columns.files.visible" label="资料" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" />
<el-checkbox v-model="columns.id.visible" label="ID" :disabled="!userStore.hasPermission(permissionMap.id)" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" :disabled="!userStore.hasPermission(permissionMap.companyName)" />
<el-checkbox v-model="columns.name.visible" label="名称" :disabled="!userStore.hasPermission(permissionMap.name)" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" :disabled="!userStore.hasPermission(permissionMap.commonName)" />
<el-checkbox v-model="columns.category.visible" label="类别" :disabled="!userStore.hasPermission(permissionMap.category)" />
<el-checkbox v-model="columns.type.visible" label="类型" :disabled="!userStore.hasPermission(permissionMap.type)" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" :disabled="!userStore.hasPermission(permissionMap.spec)" />
<el-checkbox v-model="columns.unit.visible" label="单位" :disabled="!userStore.hasPermission(permissionMap.unit)" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" :disabled="!userStore.hasPermission(permissionMap.inventory)" />
<el-checkbox v-model="columns.available.visible" label="可用数" :disabled="!userStore.hasPermission(permissionMap.available)" />
<el-checkbox v-model="columns.files.visible" label="资料" :disabled="!userStore.hasPermission(permissionMap.files)" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" :disabled="!userStore.hasPermission(permissionMap.isEnabled)" />
</div>
</el-popover>
</div>
@ -121,6 +121,7 @@
border
stripe
:size="tableSize"
@sort-change="handleSortChange"
style="width: 100%; margin-top: 15px"
>
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
@ -149,7 +150,7 @@
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip />
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" />
<el-table-column v-if="columns.inventory.visible" prop="inventoryCount" label="库存数" min-width="100" align="center">
<el-table-column v-if="columns.inventory.visible" prop="inventoryCount" label="库存数" min-width="100" align="center" sortable="custom">
<template #default="{ row }">
<span :style="{ fontWeight: 'bold', color: row.inventoryCount > 0 ? '#67C23A' : '#909399' }">
{{ row.inventoryCount }}
@ -157,7 +158,7 @@
</template>
</el-table-column>
<el-table-column v-if="columns.available.visible" prop="availableCount" label="可用数" min-width="100" align="center">
<el-table-column v-if="columns.available.visible" prop="availableCount" label="可用数" min-width="100" align="center" sortable="custom">
<template #default="{ row }">
<span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : '#909399' }">
{{ row.availableCount }}
@ -210,14 +211,15 @@
:active-value="1"
:inactive-value="0"
:loading="scope.row.statusLoading"
:disabled="!userStore.hasPermission('material_list:operation')"
@change="handleStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right" align="center">
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" min-width="150" fixed="right" align="center">
<template #default="scope">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@ -246,12 +248,12 @@
<el-row>
<el-col :span="12">
<el-form-item label="名称" prop="name">
<el-form-item label="名称" prop="name" v-if="hasFieldPermission('name')">
<el-input v-model="form.name" placeholder="内部名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="俗名" prop="commonName">
<el-form-item label="俗名" prop="commonName" v-if="hasFieldPermission('commonName')">
<el-input v-model="form.commonName" placeholder="标准名称" />
</el-form-item>
</el-col>
@ -259,7 +261,7 @@
<el-row>
<el-col :span="12">
<el-form-item label="所属公司" prop="companyName">
<el-form-item label="所属公司" prop="companyName" v-if="hasFieldPermission('companyName')">
<el-autocomplete
v-model="form.companyName"
:fetch-suggestions="querySearchCompany"
@ -270,7 +272,7 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="类别" prop="category">
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
<div style="display: flex; width: 100%; align-items: center;">
<el-cascader
v-model="tempCategoryPrefix"
@ -298,7 +300,7 @@
<el-row>
<el-col :span="12">
<el-form-item label="类型" prop="type">
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
@ -309,7 +311,7 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="spec">
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
@ -317,7 +319,7 @@
<el-row>
<el-col :span="12">
<el-form-item label="计量单位" prop="unit">
<el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')">
<el-input v-model="form.unit" placeholder=": , , " />
</el-form-item>
</el-col>
@ -329,7 +331,7 @@
</el-col>
</el-row>
<el-form-item label="产品图" prop="generalImage">
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
<div class="upload-container">
<el-upload
v-model:file-list="fileListImage"
@ -357,7 +359,7 @@
</el-input>
</el-form-item>
<el-form-item label="说明书" prop="generalManual">
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
<div class="upload-container">
<el-upload
v-model:file-list="fileListManual"
@ -385,7 +387,7 @@
</el-input>
</el-form-item>
<el-form-item label="状态" prop="isEnabled">
<el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')">
<el-radio-group v-model="form.isEnabled">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
@ -420,6 +422,7 @@ import { ref, reactive, onMounted, nextTick } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
import {
listMaterialBase,
@ -432,6 +435,8 @@ import {
import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
const userStore = useUserStore();
// --- 类型定义 ---
interface MaterialBaseVO {
id: number;
@ -459,6 +464,8 @@ interface QueryParams {
type: string;
company: string;
isEnabled?: number;
orderByColumn: string;
isAsc: string | undefined;
}
interface CascaderOption {
@ -501,6 +508,52 @@ const columns = reactive({
isEnabled: { visible: true }
});
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'material_list:id',
companyName: 'material_list:companyName',
name: 'material_list:name',
commonName: 'material_list:commonName',
category: 'material_list:category',
type: 'material_list:type',
spec: 'material_list:spec',
unit: 'material_list:unit',
inventory: 'material_list:inventoryCount',
available: 'material_list:availableCount',
files: 'material_list:files',
isEnabled: 'material_list:isEnabled'
};
// 根据用户权限初始化列显示状态
const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return;
}
// 普通用户:严格执行列级权限控制,没有权限的列必须隐藏
Object.keys(columns).forEach(key => {
const code = permissionMap[key];
if (code) {
// 如果不具备该权限,必须设为 false
columns[key].visible = !!userStore.hasPermission(code);
}
});
};
// 检查字段权限(用于表单)
const hasFieldPermission = (field: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true;
}
const code = permissionMap[field];
// 如果permissionMap中没有该字段默认允许
if (!code) {
return true;
}
return userStore.hasPermission(code);
};
const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
@ -516,7 +569,9 @@ const queryParams = reactive<QueryParams>({
category: '',
type: '',
company: '',
isEnabled: undefined
isEnabled: undefined,
orderByColumn: '',
isAsc: undefined
});
// --- 弹窗与表单相关 ---
@ -704,6 +759,18 @@ const handleInputSearch = () => {
}, 500);
};
const handleSortChange = ({ column, prop, order }: any) => {
if (prop && (prop === 'inventoryCount' || prop === 'availableCount')) {
queryParams.orderByColumn = prop;
queryParams.isAsc = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined;
} else {
queryParams.orderByColumn = '';
queryParams.isAsc = undefined;
}
queryParams.pageNum = 1;
getList();
};
const handleQuery = () => {
queryParams.pageNum = 1;
getList();
@ -715,6 +782,8 @@ const resetQuery = () => {
queryParams.type = '';
queryParams.company = '';
queryParams.isEnabled = undefined;
queryParams.orderByColumn = '';
queryParams.isAsc = undefined;
handleQuery();
};
@ -988,6 +1057,8 @@ const handleCameraConfirm = async (file: File) => {
};
onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions();
getList();
getOptionsList();
});
@ -1043,4 +1114,4 @@ onMounted(() => {
.long-dropdown .el-select-dropdown__wrap {
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
}
</style>
</style>

View File

@ -8,14 +8,14 @@
<span class="subtitle">(请添加需要出库的物品)</span>
</div>
<div>
<el-button type="primary" :icon="Plus" @click="openManualSelect">
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Plus" @click="openManualSelect">
手动添加库存
</el-button>
<el-button type="warning" :icon="List" @click="openBomSelect">
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加
</el-button>
<el-divider direction="vertical" />
<el-button type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成预览 & 打印
</el-button>
</div>
@ -71,6 +71,7 @@
size="small"
style="width: 100%"
controls-position="right"
:disabled="!userStore.hasPermission('outbound_selection:operation')"
@change="(val) => handleMainQuantityChange(val, row)"
/>
</template>
@ -78,7 +79,7 @@
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link @click="removeRow($index)">移除</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="danger" link @click="removeRow($index)">移除</el-button>
</template>
</el-table-column>
</el-table>
@ -133,6 +134,7 @@
size="small"
style="width: 100%"
placeholder="0"
:disabled="!userStore.hasPermission('outbound_selection:operation')"
@click.stop
@change="(val) => handleManualQuantityChange(val, row)"
/>
@ -144,7 +146,7 @@
已勾选 {{ tempSelection.length }}
</span>
<el-button @click="manualDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmManualAdd">确认添加</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" @click="confirmManualAdd">确认添加</el-button>
</template>
</el-dialog>
@ -161,7 +163,7 @@
</el-select>
</el-form-item>
<el-form-item label="生产套数">
<el-input-number v-model="bomSets" :min="1" label="套" style="width: 200px;" />
<el-input-number v-model="bomSets" :min="1" label="套" style="width: 200px;" :disabled="!userStore.hasPermission('outbound_selection:operation')" />
</el-form-item>
</el-form>
<div style="margin-left: 100px; color: #909399; font-size: 12px;">
@ -169,7 +171,7 @@
</div>
<template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button>
<el-button type="primary" @click="confirmBomAdd">一键计算并添加</el-button>
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" @click="confirmBomAdd">一键计算并添加</el-button>
</template>
</el-dialog>
@ -204,11 +206,11 @@
<span class="dialog-footer">
<el-button @click="previewVisible = false">取消</el-button>
<el-button type="warning" :icon="Download" :loading="exportLoading" @click="confirmExport">
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="Download" :loading="exportLoading" @click="confirmExport">
导出 Excel
</el-button>
<el-button type="primary" :icon="Printer" :loading="printLoading" @click="confirmPrint">
<el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="primary" :icon="Printer" :loading="printLoading" @click="confirmPrint">
确认打印 (A4)
</el-button>
</span>
@ -283,6 +285,9 @@ import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态变量 ---
const selectedItems = ref<any[]>([])
@ -598,4 +603,4 @@ const confirmExport = () => {
.sig-label { font-size: 14px; margin-bottom: 40px; text-align: left; width: 100%; }
.sig-line { border-bottom: 1px solid #000; width: 100%; height: 1px; display: block; }
}
</style>
</style>

View File

@ -17,10 +17,14 @@
<div class="scan-section">
<div class="camera-placeholder" @click="showCamera = true">
<div v-if="userStore.hasPermission('outbound_create:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
@ -30,12 +34,13 @@
clearable
ref="barcodeRef"
size="large"
:disabled="!userStore.hasPermission('outbound_create:operation')"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput">添加</el-button>
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('outbound_create:operation')">添加</el-button>
</template>
</el-input>
</div>
@ -64,13 +69,14 @@
:max="parseFloat(row.available_quantity)"
size="small"
style="width: 100px"
:disabled="!userStore.hasPermission('outbound_create:operation')"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
</el-table-column>
</el-table>
@ -120,7 +126,7 @@
</el-form-item>
<el-form-item label="电子签名确认" required>
<div class="signature-box" @click="openSignatureDialog">
<div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('outbound_create:operation')">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
@ -130,11 +136,17 @@
<span>点击此处进行全屏签名</span>
</div>
</div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
<el-button v-if="userStore.hasPermission('outbound_create:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认出库
</el-button>
</div>
@ -205,6 +217,8 @@ import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbou
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态定义 ---
const barcodeInput = ref('')
const cartItems = ref<any[]>([])
@ -212,7 +226,6 @@ const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
const userStore = useUserStore()
// 签名相关
const showSignatureDialog = ref(false)
@ -292,6 +305,10 @@ const onScanSuccess = (code: string) => {
}
const handleManualInput = async () => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
const code = barcodeInput.value.trim()
if (!code) return
@ -355,10 +372,18 @@ const handleManualInput = async () => {
}
const removeFromCart = (index: number) => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
cartItems.value.splice(index, 1)
}
const clearAll = () => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
ElMessageBox.confirm('确定清空所有已选商品吗?', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
@ -372,6 +397,10 @@ const clearAll = () => {
// --- 提交逻辑 ---
const submitForm = async () => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无操作权限')
return
}
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
@ -427,7 +456,13 @@ const submitForm = async () => {
}
// --- 签名逻辑 (Canvas) ---
const openSignatureDialog = () => { showSignatureDialog.value = true }
const openSignatureDialog = () => {
if (!userStore.hasPermission('outbound_create:operation')) {
ElMessage.warning('无签名权限')
return
}
showSignatureDialog.value = true
}
const initCanvas = async () => {
await nextTick()
@ -618,4 +653,4 @@ onUnmounted(() => {
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>
</style>

View File

@ -20,7 +20,7 @@
value-format="YYYY-MM-DD"
/>
<el-button type="primary" class="filter-item" style="margin-left: 10px;" @click="fetchData">查询</el-button>
<el-button type="success" class="filter-item" @click="$router.push('/outbound/create')">新建出库</el-button>
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="success" class="filter-item" @click="$router.push('/outbound/create')">新建出库</el-button>
</div>
<el-table
@ -35,18 +35,18 @@
<div style="padding: 10px 40px; background: #fafafa;">
<h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4>
<el-table :data="props.row.items" border size="small">
<el-table-column prop="sku" label="SKU" width="150" />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="150" />
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="material_type" label="类型" width="120" show-overflow-tooltip />
<el-table-column prop="category" label="类别" width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('name')" prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('material_type')" prop="material_type" label="类型" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('category')" prop="category" label="类别" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('spec_model')" prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="unit_price" label="单价" width="120">
<el-table-column v-if="hasColumnPermission('quantity')" prop="quantity" label="数量" width="100" />
<el-table-column v-if="hasColumnPermission('unit_price')" prop="unit_price" label="单价" width="120">
<template #default="{row}">¥{{ row.unit_price }}</template>
</el-table-column>
<el-table-column prop="subtotal" label="小计">
<el-table-column v-if="hasColumnPermission('subtotal')" prop="subtotal" label="小计">
<template #default="{row}">
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
</template>
@ -56,33 +56,33 @@
</template>
</el-table-column>
<el-table-column prop="outbound_no" label="出库单号" min-width="200" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('outbound_no')" prop="outbound_no" label="出库单号" min-width="200" show-overflow-tooltip />
<el-table-column prop="outbound_time" label="出库时间" width="170" align="center">
<el-table-column v-if="hasColumnPermission('outbound_time')" prop="outbound_time" label="出库时间" width="170" align="center">
<template #default="{ row }">
<span>{{ row.outbound_time ? row.outbound_time.substring(0, 16) : '' }}</span>
</template>
</el-table-column>
<el-table-column prop="outbound_type" label="类型" width="100" align="center">
<el-table-column v-if="hasColumnPermission('outbound_type')" prop="outbound_type" label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTagType(row.outbound_type)">{{ formatType(row.outbound_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_amount" label="总金额" width="120" align="right">
<el-table-column v-if="hasColumnPermission('total_amount')" prop="total_amount" label="总金额" width="120" align="right">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="consumer_name" label="领用/客户" min-width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('consumer_name')" prop="consumer_name" label="领用/客户" min-width="120" show-overflow-tooltip />
<el-table-column prop="operator_name" label="操作员" min-width="100" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('operator_name')" prop="operator_name" label="操作员" min-width="100" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('remark')" prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="签名" width="120" align="center">
<el-table-column v-if="hasColumnPermission('signature_path')" label="签名" width="120" align="center">
<template #default="{ row }">
<div v-if="row.signature_path" class="signature-cell">
<el-image
@ -121,6 +121,39 @@
import { ref, onMounted, reactive } from 'vue'
import { getOutboundList } from '@/api/outbound'
import { Picture } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
outbound_no: 'outbound_list:outbound_no',
outbound_time: 'outbound_list:outbound_time',
outbound_type: 'outbound_list:outbound_type',
total_amount: 'outbound_list:total_amount',
consumer_name: 'outbound_list:consumer_name',
operator_name: 'outbound_list:operator_name',
remark: 'outbound_list:remark',
signature_path: 'outbound_list:signature_path',
// 明细列
sku: 'outbound_list:sku',
name: 'outbound_list:name',
material_type: 'outbound_list:material_type',
category: 'outbound_list:category',
spec_model: 'outbound_list:spec_model',
quantity: 'outbound_list:quantity',
unit_price: 'outbound_list:unit_price',
subtotal: 'outbound_list:subtotal',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const list = ref([])
const total = ref(0)
@ -198,4 +231,4 @@ onMounted(() => {
background: #f5f7fa;
color: #909399;
}
</style>
</style>

View File

@ -1,7 +1,7 @@
<template>
<div class="buy-module">
<div class="header-container">
<div class="search-form-area">
<div class="header-container" style="flex-wrap: wrap;">
<div class="search-form-area" style="flex-wrap: wrap;">
<el-select
v-model="queryParams.company"
@ -55,8 +55,8 @@
<el-button class="reset-btn" @click="resetQuery">重置</el-button>
</div>
<div class="right-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="add-btn">新增</el-button>
<div class="right-actions" style="flex-wrap: wrap;">
<el-button v-if="userStore.hasPermission('inbound_buy:operation')" type="primary" :icon="Plus" @click="handleCreate" class="add-btn">新增</el-button>
<el-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
@ -67,13 +67,13 @@
<div class="col-group-title">基础信息</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
<el-checkbox :label="c.prop" :disabled="!hasColumnPermission(c.prop)">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
<div class="col-group-title" style="margin-top:10px">库存与商务</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
<el-checkbox :label="c.prop" :disabled="!hasColumnPermission(c.prop)">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
</el-checkbox-group>
@ -167,7 +167,7 @@
</el-table-column>
</template>
<el-table-column label="操作" width="220" fixed="right" align="center">
<el-table-column v-if="userStore.hasPermission('inbound_buy:operation')" label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon> 打印
@ -197,7 +197,7 @@
<el-dialog
v-model="visible"
:title="dialogStatus === 'create' ? '新增采购入库' : '编辑入库信息'"
width="1000px"
:width="'min(1000px, 95vw)'"
top="4vh"
destroy-on-close
:close-on-click-modal="false"
@ -272,12 +272,12 @@
<div class="read-only-grid">
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="所属公司" v-if="hasFormFieldPermission('company_name')"><el-input v-model="form.company_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称" v-if="hasFormFieldPermission('material_name')"><el-input v-model="form.material_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型" v-if="hasFormFieldPermission('material_type')"><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别" v-if="hasFormFieldPermission('category')"><el-input v-model="form.category" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号" v-if="hasFormFieldPermission('spec_model')"><el-input v-model="form.spec_model" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位" v-if="hasFormFieldPermission('unit')"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
</el-row>
</div>
</div>
@ -296,9 +296,6 @@
<el-col :span="6">
<el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" clearable/></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="库位" prop="warehouse_location">
<el-autocomplete
@ -346,7 +343,7 @@
<el-row :gutter="20" style="margin-top: 10px;">
<el-col :span="6">
<el-form-item label="入库数量" prop="in_quantity">
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input"/>
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input" @change="updatePrices('qty')"/>
</el-form-item>
</el-col>
@ -423,31 +420,72 @@
</el-row>
<div class="divider-text">商务与采购信息</div>
<el-row :gutter="20">
<el-col :span="6">
<el-col :span="8">
<el-form-item label="币种">
<el-autocomplete v-model="form.currency" :fetch-suggestions="querySearchCurrency" placeholder="币种" style="width: 100%" :trigger-on-focus="true">
<template #default="{ item }"><span>{{ item.value }}</span><span style="float:right; color:#999; font-size:12px">{{ item.desc }}</span></template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6">
<el-col :span="8">
<el-form-item label="汇率">
<el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="税率">
<el-select v-model="form.tax_rate" style="width:100%">
<el-select v-model="form.tax_rate" style="width:100%" @change="updatePrices('tax')">
<el-option label="0%" :value="0" />
<el-option label="1%" :value="1" />
<el-option label="13%" :value="13" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="不含税单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-row :gutter="20" style="margin-top: 15px;">
<el-col :span="8">
<el-form-item label="不含税单价" prop="unit_price">
<el-input-number
v-model="form.unit_price"
:precision="2"
:controls="false"
style="width:100%"
placeholder="请输入"
@change="updatePrices('pre')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="含税单价">
<el-input-number
v-model="form.post_tax_unit_price"
:precision="2"
:controls="false"
style="width:100%"
placeholder="请输入"
@change="updatePrices('post')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="不含税总价">
<el-input-number
v-model="form.total_price"
:precision="2"
disabled
:controls="false"
style="width:100%"
class="total-price-input"
placeholder="自动计算"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 15px;">
<el-col :span="8">
<el-form-item label="供应商">
<el-autocomplete
@ -587,6 +625,7 @@ import {
} from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { useUserStore } from '@/stores/user'
// ------------------------------------
// 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框)
@ -610,9 +649,59 @@ const vLoadmore = {
}
}
// ------------------------------------
// 表单字段权限检查
// ------------------------------------
const hasFormFieldPermission = (fieldName: string) => {
// 超级管理员直接返回true
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// 根据字段名映射到权限码
const map: Record<string, string> = {
company_name: 'inbound_buy:company_name',
material_name: 'inbound_buy:material_name',
spec_model: 'inbound_buy:spec_model',
category: 'inbound_buy:category',
material_type: 'inbound_buy:material_type',
unit: 'inbound_buy:unit',
sku: 'inbound_buy:sku',
barcode: 'inbound_buy:barcode',
in_date: 'inbound_buy:in_date',
serial_number: 'inbound_buy:serial_number',
batch_number: 'inbound_buy:batch_number',
status: 'inbound_buy:status',
inspection_status: 'inbound_buy:inspection_status',
in_quantity: 'inbound_buy:in_quantity',
stock_quantity: 'inbound_buy:stock_quantity',
available_quantity: 'inbound_buy:available_quantity',
warehouse_location: 'inbound_buy:warehouse_location',
unit_price: 'inbound_buy:unit_price',
tax_rate: 'inbound_buy:tax_rate',
total_price: 'inbound_buy:total_price',
currency: 'inbound_buy:currency',
exchange_rate: 'inbound_buy:exchange_rate',
supplier_name: 'inbound_buy:supplier_name',
purchaser: 'inbound_buy:purchaser',
purchaser_email: 'inbound_buy:purchaser_email',
source_link: 'inbound_buy:original_link',
detail_link: 'inbound_buy:detail_link',
arrival_photo: 'inbound_buy:arrival_photo',
inspection_report: 'inbound_buy:inspection_report',
print_copies: 'inbound_buy:print_copies',
}
const code = map[fieldName]
if (!code) {
// 没有映射的字段默认显示
return true
}
return userStore.hasPermission(code)
}
// ------------------------------------
// 状态与变量
// ------------------------------------
const userStore = useUserStore()
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
@ -689,7 +778,7 @@ const stockColumns = [
{prop: 'tax_rate', label: '税率', minWidth: '80'},
{prop: 'unit_price', label: '不含税单价', minWidth: '120'},
{prop: 'total_price', label: '总价', minWidth: '120'},
{prop: 'total_price', label: '不含税总价', minWidth: '120'},
{prop: 'currency', label: '币种', minWidth: '80'},
{prop: 'exchange_rate', label: '汇率', minWidth: '80'},
{prop: 'supplier_name', label: '供应商', minWidth: '150'},
@ -701,6 +790,78 @@ const stockColumns = [
{prop: 'inspection_report', label: '检测报告', minWidth: '100'}
]
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'inbound_buy:id',
base_id: 'inbound_buy:base_id',
company_name: 'inbound_buy:company_name',
material_name: 'inbound_buy:material_name',
material_type: 'inbound_buy:material_type',
category: 'inbound_buy:category',
spec_model: 'inbound_buy:spec_model',
unit: 'inbound_buy:unit',
sku: 'inbound_buy:sku',
inbound_date: 'inbound_buy:inbound_date',
barcode: 'inbound_buy:barcode',
sn_bn: 'inbound_buy:sn_bn',
status: 'inbound_buy:status',
inspection_status: 'inbound_buy:inspection_status',
qty_inbound: 'inbound_buy:qty_inbound',
qty_stock: 'inbound_buy:qty_stock',
qty_available: 'inbound_buy:qty_available',
warehouse_loc: 'inbound_buy:warehouse_loc',
tax_rate: 'inbound_buy:tax_rate',
unit_price: 'inbound_buy:unit_price',
total_price: 'inbound_buy:total_price',
currency: 'inbound_buy:currency',
exchange_rate: 'inbound_buy:exchange_rate',
supplier_name: 'inbound_buy:supplier_name',
purchaser: 'inbound_buy:purchaser',
purchaser_email: 'inbound_buy:purchaser_email',
source_link: 'inbound_buy:source_link',
detail_link: 'inbound_buy:detail_link',
arrival_photo: 'inbound_buy:arrival_photo',
inspection_report: 'inbound_buy:inspection_report'
}
// 根据用户权限初始化列显示状态
const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return
}
// 普通用户:严格执行列级权限控制,没有权限的列必须隐藏
// 遍历 allColumns将没有权限的列从 visibleColumnProps 中移除
const allowedColumns = allColumns.filter(col => {
const code = permissionMap[col.prop]
if (code) {
return userStore.hasPermission(code)
}
// 如果没有映射,默认隐藏
return false
}).map(col => col.prop)
// 更新 visibleColumnProps只保留有权限的列
// 同时保持用户之前已经选择的有权限的列
const currentVisible = visibleColumnProps.value.filter(prop => allowedColumns.includes(prop))
// 如果当前没有可见列,则使用 allowedColumns 作为默认
if (currentVisible.length === 0) {
visibleColumnProps.value = allowedColumns
} else {
visibleColumnProps.value = currentVisible
}
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const allColumns = [...baseColumns, ...stockColumns]
const defaultColumns = [
@ -719,7 +880,9 @@ const form = reactive({
material_name: '', spec_model: '', category: '', unit: '', material_type: '',
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: 0, total_price: 0,
unit_price: undefined as number | undefined,
post_tax_unit_price: undefined as number | undefined,
total_price: undefined as number | undefined,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '',
@ -727,7 +890,6 @@ const form = reactive({
print_copies: 1
})
// ------------------------------------
// 建议/Autocomplete 逻辑
// ------------------------------------
@ -916,7 +1078,45 @@ const handleEntryModeChange = (val: string) => {
if(formRef.value) formRef.value.clearValidate('batch_number')
}
}
watch(() => [form.in_quantity, form.unit_price], () => { form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4)) })
// 价格联动计算 (精确到小数点后2位并支持空值)
const updatePrices = (source: string) => {
const taxMultiplier = 1 + (form.tax_rate || 0) / 100;
if (source === 'pre') {
if (form.unit_price !== undefined && form.unit_price !== null) {
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
} else {
form.post_tax_unit_price = undefined;
}
} else if (source === 'post') {
if (form.post_tax_unit_price !== undefined && form.post_tax_unit_price !== null) {
form.unit_price = Number((form.post_tax_unit_price / taxMultiplier).toFixed(2));
} else {
form.unit_price = undefined;
}
} else if (source === 'tax') {
if (form.unit_price !== undefined && form.unit_price !== null) {
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
}
}
if (form.in_quantity !== undefined && form.unit_price !== undefined && form.unit_price !== null) {
form.total_price = Number((form.in_quantity * form.unit_price).toFixed(2));
} else {
form.total_price = undefined;
}
}
watch(() => [form.in_quantity, form.unit_price], () => {
if (form.unit_price !== undefined && form.unit_price !== null) {
form.total_price = Number((form.in_quantity * form.unit_price).toFixed(2));
// 同时更新含税单价
const taxMultiplier = 1 + (form.tax_rate || 0) / 100;
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
} else {
form.total_price = undefined;
form.post_tax_unit_price = undefined;
}
})
const fetchData = async () => {
loading.value = true
@ -975,13 +1175,20 @@ const handleUpdate = (row: any) => {
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, inspection_status: row.inspection_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
unit_price: Number(row.unit_price), total_price: Number(row.total_price),
unit_price: (row.unit_price !== null && row.unit_price !== undefined) ? Number(row.unit_price) : undefined,
total_price: (row.total_price !== null && row.total_price !== undefined) ? Number(row.total_price) : undefined,
tax_rate: Number(row.tax_rate),
currency: row.currency, exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email,
source_link: row.source_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || []
})
// 计算含税单价
if (form.unit_price !== undefined && form.unit_price !== null) {
const taxMultiplier = 1 + (form.tax_rate || 0) / 100;
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
}
arrivalFileList.value = form.arrival_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const reports = form.inspection_report || []
const reportImgs = reports.filter(r => !isExternalLink(r))
@ -1004,7 +1211,13 @@ const submitForm = async () => {
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value)
const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) }
const payload = {
...form,
inspection_report: onlyImages,
in_quantity: Number(form.in_quantity || 0),
unit_price: Number(form.unit_price || 0),
post_tax_unit_price: Number(form.post_tax_unit_price || 0)
}
try {
if (dialogStatus.value === 'create') {
const res: any = await createBuyInbound(payload)
@ -1170,16 +1383,26 @@ const resetForm = () => {
id: undefined, base_id: undefined,
company_name: '',
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: 0, total_price: 0,
unit_price: undefined, post_tax_unit_price: undefined, total_price: undefined,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [],
print_copies: 1
})
}
const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' }
const formatMoney = (val: any, currency = '¥') => { const num = Number(val); return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}` }
// 列表金额显示增加千分位处理并保留2位小数
const formatMoney = (val: any, currency = '¥') => {
const num = Number(val);
if (isNaN(num)) return '-';
const parts = num.toFixed(2).split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${currency} ${parts.join('.')}`;
}
onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData()
fetchOptions()
})
@ -1360,6 +1583,11 @@ onMounted(() => {
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; }
/* 自定义千分位无箭头输入框样式,用于强迫症优化显示 */
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
</style>
<style>

View File

@ -16,22 +16,49 @@
<el-input
v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / SN / 工单..."
placeholder="请输入名称或规格"
class="filter-item-input"
clearable
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 260px;"
style="width: 240px;"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select
v-model="queryParams.category"
placeholder="类别"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.material_type"
placeholder="类型"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-button type="primary" plain class="search-btn" @click="fetchData">搜索</el-button>
<el-button class="reset-btn" @click="resetQuery">重置</el-button>
<el-select
v-model="queryParams.statuses"
multiple
collapse-tags
placeholder="状态筛选"
style="width: 220px;"
style="width: 200px; margin-left: 10px;"
@change="fetchData"
>
<el-option label="在库" value="在库" />
@ -41,13 +68,13 @@
</div>
<div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">成品入库登记</el-button>
<el-button v-if="userStore.hasPermission('inbound_product:operation')" type="primary" :icon="Plus" @click="handleCreate" class="action-btn">成品入库登记</el-button>
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-row :gutter="10">
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :label="c.prop" :disabled="!hasColumnPermission(c.prop)">{{ c.label }}</el-checkbox></el-col>
</el-row>
</el-checkbox-group>
</el-popover>
@ -79,8 +106,7 @@
</template>
<template #default="scope" v-else-if="col.prop === 'company_name'">
<el-tag v-if="scope.row.company_name" type="info" effect="plain" size="small" style="font-weight: bold;">{{ scope.row.company_name }}</el-tag>
<span v-else>-</span>
<span>{{ scope.row.company_name || '-' }}</span>
</template>
<template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
@ -130,14 +156,14 @@
</el-link>
</template>
<template #default="scope" v-else-if="['sale_price', 'raw_material_cost', 'manual_cost'].includes(col.prop)">
<template #default="scope" v-else-if="['sale_price', 'raw_material_cost', 'unit_total_cost', 'total_price'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="180" fixed="right" align="center">
<el-table-column v-if="userStore.hasPermission('inbound_product:operation')" label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon>
@ -150,7 +176,7 @@
<el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" />
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog compact-layout">
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="false" class="stylish-dialog compact-layout">
<div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
@ -163,7 +189,7 @@
</div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10">
<el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select
v-model="form.base_id"
@ -171,7 +197,7 @@
remote
reserve-keyword
clearable
placeholder="搜名称/规格..."
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
@ -179,7 +205,7 @@
@change="onMaterialSelected"
default-first-option
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
popper-class="product-dropdown"
>
<template #prefix><el-icon><Search /></el-icon></template>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
@ -203,20 +229,20 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="14" style="display: flex; align-items: center;">
<el-col :span="12" style="display: flex; align-items: center;">
<span class="search-tip">
<el-icon><InfoFilled /></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索
<el-icon><InfoFilled /></el-icon> 支持名称、规格型号、公司名称模糊搜索
</span>
</el-col>
</el-row>
<div class="read-only-grid">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="所属公司" v-if="hasFormFieldPermission('company_name')"><el-input v-model="form.company_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称" v-if="hasFormFieldPermission('material_name')"><el-input v-model="form.material_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格" v-if="hasFormFieldPermission('spec_model')"><el-input v-model="form.spec_model" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位" v-if="hasFormFieldPermission('unit')"><el-input v-model="form.unit" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型" v-if="hasFormFieldPermission('material_type')"><el-input v-model="form.material_type" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别" v-if="hasFormFieldPermission('category')"><el-input v-model="form.category" readonly class="is-text-view" /></el-form-item></el-col>
</el-row>
</div>
</div>
@ -227,7 +253,6 @@
<div class="card-content">
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" clearable /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01" clearable /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row>
@ -352,7 +377,13 @@
<el-autocomplete v-model="form.production_manager" :fetch-suggestions="querySearchManager" placeholder="输入或选择负责人" style="width: 100%" clearable :trigger-on-focus="true" @select="handleManagerSelect"/>
</el-form-item>
</el-col>
<el-col :span="8"><el-form-item label="产品定价"><el-input-number v-model="form.sale_price" :precision="2" style="width:100%"><template #prefix>¥</template></el-input-number></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="产品定价">
<el-input-number v-model="form.sale_price" :precision="2" :controls="false" style="width:100%" placeholder="请输入">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
@ -361,8 +392,23 @@
<el-date-picker v-model="form.production_time_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="原料成本"><el-input-number v-model="form.raw_material_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="人工成本"><el-input-number v-model="form.manual_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="原料成本">
<el-input-number v-model="form.raw_material_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="单件成本">
<el-input-number v-model="form.unit_total_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="总成本">
<el-input-number v-model="form.total_price" :precision="2" :controls="false" style="width:100%" placeholder="自动计算" disabled/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24" style="margin-top:10px">
<el-col :span="24"><el-form-item label="详情链接"><el-input v-model="form.detail_link" /></el-form-item></el-col>
@ -427,11 +473,14 @@ import {
deleteProductInbound,
searchMaterialBase,
searchBom,
getFilterOptions // [新增]
getFilterOptions,
getManagerHistory, // [新增]
calculateBomCost
} from '@/api/inbound/product'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { getLabelPreview, executePrint } from '@/api/common/print'
import { useUserStore } from '@/stores/user'
// ------------------------------------
// v-loadmore
@ -439,7 +488,8 @@ import { getLabelPreview, executePrint } from '@/api/common/print'
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
const dropDownWrap = document.querySelector('.long-dropdown .el-select-dropdown__wrap')
// 这里的 .product-dropdown 是唯一标识,防止和采购/半成品页面冲突
const dropDownWrap = document.querySelector('.product-dropdown .el-select-dropdown__wrap')
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
@ -455,6 +505,7 @@ const vLoadmore = {
}
}
const userStore = useUserStore()
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
@ -464,7 +515,7 @@ const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([])
const total = ref(0)
const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'], company: '' })
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '' })
const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增]
@ -518,16 +569,81 @@ const allColumns = [
{ prop: 'bom_code', label: 'BOM', minWidth: '100' },
{ prop: 'production_manager', label: '负责人', minWidth: '100' },
{ prop: 'raw_material_cost', label: '原料成本', minWidth: '100' },
{ prop: 'manual_cost', label: '人工成本', minWidth: '100' },
{ prop: 'unit_total_cost', label: '单件成本', minWidth: '100' },
{ prop: 'total_price', label: '总成本', minWidth: '100' },
{ prop: 'inbound_date', label: '生产日期', minWidth: '120' },
{ prop: 'detail_link', label: '详情', minWidth: '100' }
]
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
company_name: 'inbound_product:company_name',
material_name: 'inbound_product:material_name',
sku: 'inbound_product:sku',
serial_number: 'inbound_product:serial_number',
qty_stock: 'inbound_product:stock_quantity',
status: 'inbound_product:status',
quality_status: 'inbound_product:quality_status',
spec_model: 'inbound_product:spec_model',
unit: 'inbound_product:unit',
product_photo: 'inbound_product:product_photo',
sale_price: 'inbound_product:sale_price',
order_id: 'inbound_product:order_id',
work_order_code: 'inbound_product:work_order_code',
quality_report_link: 'inbound_product:quality_report_link',
inspection_report_link: 'inbound_product:inspection_report_link',
bom_code: 'inbound_product:bom_code',
production_manager: 'inbound_product:production_manager',
raw_material_cost: 'inbound_product:raw_material_cost',
unit_total_cost: 'inbound_product:unit_total_cost',
total_price: 'inbound_product:total_price',
inbound_date: 'inbound_product:inbound_date',
detail_link: 'inbound_product:detail_link',
}
// 根据用户权限初始化列显示状态
const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return
}
// 普通用户:严格执行列级权限控制,没有权限的列必须隐藏
// 遍历 allColumns将没有权限的列从 visibleColumnProps 中移除
const allowedColumns = allColumns.filter(col => {
const code = permissionMap[col.prop]
if (code) {
return userStore.hasPermission(code)
}
// 如果没有映射,默认隐藏
return false
}).map(col => col.prop)
// 更新 visibleColumnProps只保留有权限的列
// 同时保持用户之前已经选择的有权限的列
const currentVisible = visibleColumnProps.value.filter(prop => allowedColumns.includes(prop))
// 如果当前没有可见列,则使用 allowedColumns 作为默认
if (currentVisible.length === 0) {
visibleColumnProps.value = allowedColumns
} else {
visibleColumnProps.value = currentVisible
}
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref(defaultVisibleCols)
const form = reactive({
id: undefined, base_id: undefined,
id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增]
material_name: '', spec_model: '', material_type: '', category: '', unit: '',
sku: '', barcode: '', serial_number: '', in_date: '',
@ -535,7 +651,10 @@ const form = reactive({
warehouse_location: '', status: '在库', quality_status: '合格',
bom_code: '', bom_version: '', work_order_code: '', order_id: '',
production_manager: '', production_time_range: [] as string[],
raw_material_cost: 0, manual_cost: 0, sale_price: 0,
raw_material_cost: undefined as number | undefined,
unit_total_cost: undefined as number | undefined,
total_price: undefined as number | undefined,
sale_price: undefined as number | undefined,
quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: ''
})
@ -549,7 +668,7 @@ const handleSearchBom = async (query: string) => {
bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false }
}
const handleBomSelect = (val: string) => {
const handleBomSelect = async (val: string) => {
if (!val) {
form.bom_code = ''
form.bom_version = ''
@ -558,6 +677,65 @@ const handleBomSelect = (val: string) => {
const [code, version] = val.split('###')
form.bom_code = code
form.bom_version = version
// 自动计算 BOM 成本并填入 raw_material_cost 和 unit_total_cost
try {
const res: any = await calculateBomCost({ bom_code: code, bom_version: version })
if (res.code === 200 && typeof res.data === 'number') {
form.raw_material_cost = res.data
form.unit_total_cost = res.data
}
} catch (e) {
// 计算失败不影响现有输入
console.warn('BOM 成本计算失败', e)
}
}
// ------------------------------------
// 表单字段权限检查
// ------------------------------------
const hasFormFieldPermission = (fieldName: string) => {
// 超级管理员直接返回true
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// 根据字段名映射到权限码
const map: Record<string, string> = {
company_name: 'inbound_product:company_name',
material_name: 'inbound_product:material_name',
spec_model: 'inbound_product:spec_model',
material_type: 'inbound_product:material_type',
category: 'inbound_product:category',
unit: 'inbound_product:unit',
sku: 'inbound_product:sku',
barcode: 'inbound_product:barcode',
serial_number: 'inbound_product:serial_number',
in_date: 'inbound_product:inbound_date',
in_quantity: 'inbound_product:in_quantity',
stock_quantity: 'inbound_product:stock_quantity',
available_quantity: 'inbound_product:available_quantity',
warehouse_location: 'inbound_product:warehouse_location',
status: 'inbound_product:status',
quality_status: 'inbound_product:quality_status',
bom_code: 'inbound_product:bom_code',
bom_version: 'inbound_product:bom_version',
work_order_code: 'inbound_product:work_order_code',
order_id: 'inbound_product:order_id',
production_manager: 'inbound_product:production_manager',
production_time_range: 'inbound_product:production_start_time',
raw_material_cost: 'inbound_product:raw_material_cost',
manual_cost: 'inbound_product:manual_cost',
sale_price: 'inbound_product:sale_price',
quality_report_link: 'inbound_product:quality_report_link',
inspection_report_link: 'inbound_product:inspection_report_link',
product_photo: 'inbound_product:product_photo',
detail_link: 'inbound_product:detail_link',
}
const code = map[fieldName]
if (!code) {
// 没有映射的字段默认显示
return true
}
return userStore.hasPermission(code)
}
// ------------------------------------
@ -582,7 +760,7 @@ const rules = {
// ------------------------------------
// Material Search & Population Logic
// Material Search & Population Logic (已修改)
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
@ -601,9 +779,9 @@ const handleSearchMaterial = async (query: string) => {
try {
const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
hasNextPage.value = res.has_next
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
}
@ -613,10 +791,10 @@ const loadMoreMaterials = async () => {
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next
hasNextPage.value = res.data.has_next
} else {
hasNextPage.value = false
}
@ -640,12 +818,19 @@ const onMaterialSelected = (val: number) => {
}
// ------------------------------------
// Autocomplete (Manager) - 后端驱动
// Autocomplete (Manager) - 后端历史记录驱动 (已修改为全局)
// ------------------------------------
const querySearchManager = async (query: string, cb: any) => {
cb([])
try {
const res: any = await getManagerHistory({ keyword: query })
if (res.code === 200) {
const managers = (res.data || []).map((name: string) => ({ value: name }))
cb(managers)
} else { cb([]) }
} catch (e) { cb([]) }
}
const handleManagerSelect = (item: any) => {
form.production_manager = item.value
}
const fetchData = async () => {
@ -696,10 +881,14 @@ const handleUpdate = (row: any) => {
quality_report_link: row.quality_report_link || [],
inspection_report_link: row.inspection_report_link || [],
in_quantity: Number(row.qty_inbound),
raw_material_cost: Number(row.raw_material_cost),
manual_cost: Number(row.manual_cost),
sale_price: Number(row.sale_price)
raw_material_cost: (row.raw_material_cost !== null && row.raw_material_cost !== undefined) ? Number(row.raw_material_cost) : undefined,
unit_total_cost: (row.unit_total_cost !== null && row.unit_total_cost !== undefined) ? Number(row.unit_total_cost) : undefined,
sale_price: (row.sale_price !== null && row.sale_price !== undefined) ? Number(row.sale_price) : undefined
})
// 计算总成本
const u = Number(form.unit_total_cost || 0)
const q = Number(form.in_quantity || 1)
form.total_price = Number((u * q).toFixed(2))
if(row.production_start_time && row.production_end_time) { form.production_time_range = [row.production_start_time, row.production_end_time] } else { form.production_time_range = [] }
productPhotoList.value = form.product_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const qReports = form.quality_report_link || []
@ -802,7 +991,17 @@ const submitForm = async () => {
const iImages = iList.filter(item => !isExternalLink(item))
if (inspection_url.value && !iList.includes(inspection_url.value)) iImages.push(inspection_url.value)
else if (inspection_url.value) iImages.push(inspection_url.value)
const payload = { ...form, quality_report_link: qImages, inspection_report_link: iImages, production_start_time: form.production_time_range?.[0], production_end_time: form.production_time_range?.[1] }
const payload = {
...form,
quality_report_link: qImages,
inspection_report_link: iImages,
raw_material_cost: Number(form.raw_material_cost || 0),
unit_total_cost: Number(form.unit_total_cost || 0),
total_price: Number(form.total_price || 0),
sale_price: Number(form.sale_price || 0),
production_start_time: form.production_time_range?.[0],
production_end_time: form.production_time_range?.[1]
}
delete payload.production_time_range
try {
if(dialogStatus.value === 'create') {
@ -828,22 +1027,31 @@ const handlePrint = async (row: any) => {
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('已发送'); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: 0, manual_cost: 0, sale_price: 0, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined, sale_price: undefined, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
}
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info')
const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData()
fetchOptions()
})
// 成本计算监听
watch([() => form.unit_total_cost, () => form.in_quantity], ([unit, qty]) => {
const unitNum = Number(unit || 0)
const qtyNum = Number(qty || 1)
form.total_price = Number((unitNum * qtyNum).toFixed(2))
})
</script>
<style scoped>
.product-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
.right-tools { display: flex; gap: 10px; align-items: center; }
.header-tools { display: flex; justify-content: space-between; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); flex-wrap: wrap; }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; flex-wrap: wrap; }
.right-tools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.modern-table { border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
@ -893,16 +1101,24 @@ onMounted(() => {
.filter-item-input { /* 宽度已在行内样式控制 */ }
.action-btn { font-weight: 500; }
.search-btn { background-color: #E6F1FC; border-color: #A3D0FD; color: #409EFF; }
.search-btn:hover { background-color: #409EFF; border-color: #409EFF; color: #fff; }
.reset-btn { background-color: #fff; border: 1px solid #dcdfe6; }
.reset-btn:hover { border-color: #c0c4cc; color: #606266; }
/* [新增] 修复弹窗最小高度 */
.dialog-scroll-container { min-height: 450px; }
/* [新增] 纯文本样式 */
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: transparent !important; border-bottom: 1px dashed #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #303133; font-weight: 600; font-size: 14px; cursor: text; }
/* 左对齐数字框 */
:deep(.el-input-number .el-input__inner) { text-align: left; }
</style>
<style>
.long-dropdown { width: 580px !important; }
.long-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
.long-dropdown .el-input__suffix { z-index: 10; }
</style>
.product-dropdown { width: 580px !important; }
.product-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
.product-dropdown .el-input__suffix { z-index: 10; }
</style>

View File

@ -69,7 +69,7 @@
</div>
<div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="add-btn">半成品入库</el-button>
<el-button v-if="userStore.hasPermission('inbound_semi:operation')" type="primary" :icon="Plus" @click="handleCreate" class="add-btn">半成品入库</el-button>
<el-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
@ -80,13 +80,13 @@
<div class="col-group-title">基础信息</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
<el-checkbox :label="c.prop" :disabled="!hasColumnPermission(c.prop)">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
<div class="col-group-title" style="margin-top:10px">生产与库存</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
<el-checkbox :label="c.prop" :disabled="!hasColumnPermission(c.prop)">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
</el-checkbox-group>
@ -119,8 +119,7 @@
</template>
<template #default="scope" v-else-if="col.prop === 'company_name'">
<el-tag v-if="scope.row.company_name" type="info" effect="plain" size="small" style="font-weight: bold;">{{ scope.row.company_name }}</el-tag>
<span v-else>-</span>
<span>{{ scope.row.company_name || '-' }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'sn_bn'">
@ -183,13 +182,13 @@
</el-link>
</template>
<template #default="scope" v-else-if="['raw_material_cost', 'manual_cost', 'unit_total_cost'].includes(col.prop)">
<template #default="scope" v-else-if="['raw_material_cost', 'unit_total_cost', 'total_price'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="220" fixed="right" align="center">
<el-table-column v-if="userStore.hasPermission('inbound_semi:operation')" label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon> 打印
@ -219,7 +218,7 @@
<el-dialog
v-model="visible"
:title="dialogStatus === 'create' ? '新增半成品入库' : '编辑半成品信息'"
width="1050px"
width="min(1000px, 95vw)"
top="5vh"
destroy-on-close
:close-on-click-modal="false"
@ -293,12 +292,12 @@
<div class="read-only-grid">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="所属公司" v-if="hasFormFieldPermission('company_name')"><el-input v-model="form.company_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称" v-if="hasFormFieldPermission('material_name')"><el-input v-model="form.material_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号" v-if="hasFormFieldPermission('spec_model')"><el-input v-model="form.spec_model" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位" v-if="hasFormFieldPermission('unit')"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别" v-if="hasFormFieldPermission('category')"><el-input v-model="form.category" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型" v-if="hasFormFieldPermission('material_type')"><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
</el-row>
</div>
</div>
@ -314,7 +313,6 @@
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" clearable/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01" clearable/></el-form-item></el-col>
</el-row>
@ -467,11 +465,23 @@
</el-col>
</el-row>
<div class="divider-text">成本核算 (单件)</div>
<div class="divider-text">成本核算</div>
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="原材料成本"><el-input-number v-model="form.raw_material_cost" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="手动/工时"><el-input-number v-model="form.manual_cost" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单件总成本"><el-input-number v-model="form.unit_total_cost" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="原材料成本">
<el-input-number v-model="form.raw_material_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="单件成本">
<el-input-number v-model="form.unit_total_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="总成本">
<el-input-number v-model="form.total_price" :precision="2" :controls="false" style="width:100%" placeholder="自动计算" disabled/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24" style="margin-top:10px">
@ -528,11 +538,14 @@ import {
deleteSemiInbound,
searchMaterialBase,
searchBom,
getFilterOptions
getFilterOptions,
getManagerHistory, // [新增]
calculateBomCost
} from '@/api/inbound/semi'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import {getLabelPreview, executePrint} from '@/api/common/print'
import { useUserStore } from '@/stores/user'
// ------------------------------------
// 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框)
@ -559,6 +572,7 @@ const vLoadmore = {
// ------------------------------------
// 状态与变量
// ------------------------------------
const userStore = useUserStore()
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
@ -629,8 +643,8 @@ const stockColumns = [
{prop: 'bom_version', label: 'BOM版本', minWidth: '90'},
{prop: 'work_order_code', label: '工单号', minWidth: '120'},
{prop: 'raw_material_cost', label: '原料成本', minWidth: '100'},
{prop: 'manual_cost', label: '人工成本', minWidth: '100'},
{prop: 'unit_total_cost', label: '单件总本', minWidth: '100'},
{prop: 'unit_total_cost', label: '单件成本', minWidth: '100'},
{prop: 'total_price', label: '总本', minWidth: '100'},
{prop: 'production_manager', label: '生产负责人', minWidth: '100'},
{prop: 'production_start_time', label: '生产开始', minWidth: '160'},
{prop: 'production_end_time', label: '生产结束', minWidth: '160'},
@ -640,13 +654,89 @@ const stockColumns = [
]
const allColumns = [...baseColumns, ...stockColumns]
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'inbound_semi:id',
base_id: 'inbound_semi:base_id',
company_name: 'inbound_semi:company_name',
material_name: 'inbound_semi:material_name',
category: 'inbound_semi:category',
material_type: 'inbound_semi:material_type',
spec_model: 'inbound_semi:spec_model',
unit: 'inbound_semi:unit',
sku: 'inbound_semi:sku',
inbound_date: 'inbound_semi:inbound_date',
barcode: 'inbound_semi:barcode',
sn_bn: 'inbound_semi:sn_bn',
status: 'inbound_semi:status',
quality_status: 'inbound_semi:quality_status',
qty_inbound: 'inbound_semi:qty_inbound',
qty_stock: 'inbound_semi:qty_stock',
qty_available: 'inbound_semi:qty_available',
warehouse_loc: 'inbound_semi:warehouse_loc',
bom_code: 'inbound_semi:bom_code',
bom_version: 'inbound_semi:bom_version',
work_order_code: 'inbound_semi:work_order_code',
raw_material_cost: 'inbound_semi:raw_material_cost',
unit_total_cost: 'inbound_semi:unit_total_cost',
total_price: 'inbound_semi:total_price',
production_manager: 'inbound_semi:production_manager',
production_start_time: 'inbound_semi:production_start_time',
production_end_time: 'inbound_semi:production_end_time',
arrival_photo: 'inbound_semi:arrival_photo',
quality_report_link: 'inbound_semi:quality_report_link',
detail_link: 'inbound_semi:detail_link',
}
// 根据用户权限初始化列显示状态
const initColumnPermissions = () => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return
}
const allowedColumns = allColumns.filter(col => {
const code = permissionMap[col.prop]
if (code) {
return userStore.hasPermission(code)
}
return false
}).map(col => col.prop)
const currentVisible = visibleColumnProps.value.filter(prop => allowedColumns.includes(prop))
if (currentVisible.length === 0) {
visibleColumnProps.value = allowedColumns
} else {
visibleColumnProps.value = currentVisible
}
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
const visibleColumnProps = ref(defaultColumns)
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增]
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: ''
company_name: '',
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '',
raw_material_cost: undefined as number | undefined,
unit_total_cost: undefined as number | undefined,
total_price: undefined as number | undefined,
production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: ''
})
// === 监听计算总成本 ===
watch([() => form.unit_total_cost, () => form.in_quantity], ([unit, qty]) => {
const unitNum = Number(unit || 0)
const qtyNum = Number(qty || 1)
form.total_price = Number((unitNum * qtyNum).toFixed(2))
})
// ------------------------------------
@ -659,7 +749,7 @@ const handleSearchBom = async (query: string) => {
bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false }
}
const handleBomSelect = (val: string) => {
const handleBomSelect = async (val: string) => {
// val 格式为 bom_no###version
if (!val) {
form.bom_code = ''
@ -669,15 +759,33 @@ const handleBomSelect = (val: string) => {
const [code, version] = val.split('###')
form.bom_code = code
form.bom_version = version
// 自动计算 BOM 成本并填入 raw_material_cost 和 unit_total_cost
try {
const res: any = await calculateBomCost({ bom_code: code, bom_version: version })
if (res.code === 200 && typeof res.data === 'number') {
form.raw_material_cost = res.data
form.unit_total_cost = res.data
}
} catch (e) {
// 计算失败不影响现有输入
console.warn('BOM 成本计算失败', e)
}
}
// ------------------------------------
// Autocomplete & Search Logic (后端 API 驱动)
// Autocomplete & Search Logic (后端 API 驱动,全局检索)
// ------------------------------------
const querySearchManager = async (query: string, cb: any) => {
cb([])
try {
const res: any = await getManagerHistory({ keyword: query })
if (res.code === 200) {
const managers = (res.data || []).map((name: string) => ({ value: name }))
cb(managers)
} else { cb([]) }
} catch (e) { cb([]) }
}
const handleManagerSelect = (item: any) => {
form.production_manager = item.value
}
// ------------------------------------
@ -700,9 +808,9 @@ const handleSearchMaterial = async (query: string) => {
try {
const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults
hasNextPage.value = res.has_next
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
}
@ -712,10 +820,10 @@ const loadMoreMaterials = async () => {
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next
hasNextPage.value = res.data.has_next
} else {
hasNextPage.value = false
}
@ -765,6 +873,50 @@ const rules = {
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}]
}
// ------------------------------------
// 表单字段权限检查
// ------------------------------------
const hasFormFieldPermission = (fieldName: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const map: Record<string, string> = {
company_name: 'inbound_semi:company_name',
material_name: 'inbound_semi:material_name',
spec_model: 'inbound_semi:spec_model',
category: 'inbound_semi:category',
material_type: 'inbound_semi:material_type',
unit: 'inbound_semi:unit',
sku: 'inbound_semi:sku',
in_date: 'inbound_semi:inbound_date',
barcode: 'inbound_semi:barcode',
serial_number: 'inbound_semi:serial_number',
batch_number: 'inbound_semi:batch_number',
status: 'inbound_semi:status',
quality_status: 'inbound_semi:quality_status',
in_quantity: 'inbound_semi:in_quantity',
stock_quantity: 'inbound_semi:stock_quantity',
available_quantity: 'inbound_semi:available_quantity',
warehouse_location: 'inbound_semi:warehouse_location',
bom_code: 'inbound_semi:bom_code',
bom_version: 'inbound_semi:bom_version',
work_order_code: 'inbound_semi:work_order_code',
raw_material_cost: 'inbound_semi:raw_material_cost',
manual_cost: 'inbound_semi:manual_cost',
unit_total_cost: 'inbound_semi:unit_total_cost',
production_manager: 'inbound_semi:production_manager',
production_time_range: 'inbound_semi:production_start_time',
arrival_photo: 'inbound_semi:arrival_photo',
quality_report_link: 'inbound_semi:quality_report_link',
detail_link: 'inbound_semi:detail_link',
}
const code = map[fieldName]
if (!code) {
return true
}
return userStore.hasPermission(code)
}
// ------------------------------------
// Core Logic
// ------------------------------------
@ -790,7 +942,6 @@ const handleEntryModeChange = (val: string) => {
if (val === 'batch') { form.serial_number = ''; form.batch_number = '000001'; if(formRef.value) formRef.value.clearValidate('serial_number') }
else { form.batch_number = ''; if(formRef.value) formRef.value.clearValidate('batch_number') }
}
watch(() => [form.raw_material_cost, form.manual_cost], () => { form.unit_total_cost = Number((form.raw_material_cost + form.manual_cost).toFixed(2)) })
const fetchData = async () => {
loading.value = true
@ -847,12 +998,17 @@ const handleUpdate = (row: any) => {
warehouse_location: row.warehouse_loc, status: row.status, quality_status: row.quality_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
bom_code: row.bom_code, bom_version: row.bom_version, work_order_code: row.work_order_code,
raw_material_cost: Number(row.raw_material_cost) || 0, manual_cost: Number(row.manual_cost) || 0,
raw_material_cost: (row.raw_material_cost !== null && row.raw_material_cost !== undefined) ? Number(row.raw_material_cost) : undefined,
unit_total_cost: (row.unit_total_cost !== null && row.unit_total_cost !== undefined) ? Number(row.unit_total_cost) : undefined,
production_manager: row.production_manager,
production_time_range: (row.production_start_time && row.production_end_time) ? [row.production_start_time, row.production_end_time] : [],
detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], quality_report_link: row.quality_report_link || []
})
// 计算总成本
const u = Number(form.unit_total_cost || 0)
const q = Number(form.in_quantity || 1)
form.total_price = Number((u * q).toFixed(2))
arrivalFileList.value = form.arrival_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const reports = form.quality_report_link || []
const reportImgs = reports.filter(r => !isExternalLink(r))
@ -945,7 +1101,16 @@ const submitForm = async () => {
if (quality_report_url.value && !finalReportList.includes(quality_report_url.value)) finalReportList.push(quality_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (quality_report_url.value) onlyImages.push(quality_report_url.value)
const payload: any = { ...form, quality_report_link: onlyImages, in_quantity: Number(form.in_quantity), raw_material_cost: Number(form.raw_material_cost), manual_cost: Number(form.manual_cost), production_start_time: form.production_time_range?.[0] || null, production_end_time: form.production_time_range?.[1] || null }
const payload: any = {
...form,
quality_report_link: onlyImages,
in_quantity: Number(form.in_quantity),
raw_material_cost: Number(form.raw_material_cost || 0),
unit_total_cost: Number(form.unit_total_cost || 0),
total_price: Number(form.total_price || 0),
production_start_time: form.production_time_range?.[0] || null,
production_end_time: form.production_time_range?.[1] || null
}
delete payload.production_time_range
try {
if (dialogStatus.value === 'create') {
@ -978,13 +1143,17 @@ const resetForm = () => {
Object.assign(form, {
id: undefined, base_id: undefined,
company_name: '', // [新增]
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [], arrival_photo: [], quality_report_link: [], detail_link: '' })
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '',
raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined,
production_manager: '', production_time_range: [], arrival_photo: [], quality_report_link: [], detail_link: '' })
}
const getStatusType = (status: string) => { const map: any = { '在库': 'success', '出库': 'info', '借库': 'warning', '损耗': 'danger' }; return map[status] || 'warning' }
const getQualityType = (status: string) => { const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }; return map[status] || 'info' }
const formatMoney = (val: any) => { const num = Number(val); return isNaN(num) ? '-' : `¥ ${num.toFixed(2)}` }
const formatMoney = (val: any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData()
fetchOptions()
})
@ -992,9 +1161,9 @@ onMounted(() => {
<style scoped>
.semi-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
.right-tools { display: flex; gap: 10px; align-items: center; }
.header-tools { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; flex-wrap: wrap; }
.right-tools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.action-btn { font-weight: 500; }
.modern-table { border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; font-weight: 600; height: 50px; }
@ -1056,10 +1225,13 @@ onMounted(() => {
.opt-spec { color: #999; font-size: 12px; }
.opt-tags { display: flex; gap: 5px; flex-shrink: 0; }
.company-tag { font-weight: bold; }
/* 左对齐数字框 */
:deep(.el-input-number .el-input__inner) { text-align: left; }
</style>
<style>
.long-dropdown { width: 580px !important; }
.long-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
.long-dropdown .el-input__suffix { z-index: 10; }
</style>
</style>

View File

@ -45,21 +45,21 @@
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button type="success" @click="handleAdd">新增服务</el-button>
<el-button v-if="userStore.hasPermission('inbound_service:operation')" type="success" @click="handleAdd">新增服务</el-button>
</el-form-item>
</el-form>
</div>
<el-table :data="tableData" border stripe style="width: 100%;" v-loading="loading">
<el-table-column prop="sku" label="SKU" width="200" />
<el-table-column prop="material_name" label="物料名称" />
<el-table-column prop="provider_name" label="服务商" width="150" />
<el-table-column prop="sale_price" label="售价" width="120">
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="200" />
<el-table-column v-if="hasColumnPermission('material_name')" prop="material_name" label="物料名称" />
<el-table-column v-if="hasColumnPermission('provider_name')" prop="provider_name" label="服务商" width="150" />
<el-table-column v-if="hasColumnPermission('sale_price')" prop="sale_price" label="售价" width="120">
<template #default="{row}">{{ row.sale_price.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="description" label="简介" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="160" />
<el-table-column label="操作" width="180" fixed="right">
<el-table-column v-if="hasColumnPermission('description')" prop="description" label="简介" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('created_at')" prop="created_at" label="创建时间" width="160" />
<el-table-column v-if="userStore.hasPermission('inbound_service:operation')" label="操作" width="180" fixed="right">
<template #default="{row}">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
@ -134,11 +134,11 @@
<div class="read-only-grid" v-if="form.base_id">
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称" v-if="hasFormFieldPermission('material_name')"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型" v-if="hasFormFieldPermission('material_type')"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别" v-if="hasFormFieldPermission('category')"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号" v-if="hasFormFieldPermission('spec_model')"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位" v-if="hasFormFieldPermission('unit')"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
</el-row>
</div>
</div>
@ -150,7 +150,7 @@
<span>2. 服务详情</span>
</div>
<div class="card-content">
<el-form-item label="售价" prop="sale_price">
<el-form-item label="售价" prop="sale_price" v-if="hasFormFieldPermission('sale_price')">
<el-input-number
v-model="form.sale_price"
placeholder="请输入售价"
@ -160,7 +160,7 @@
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="服务商" prop="provider_name">
<el-form-item label="服务商" prop="provider_name" v-if="hasFormFieldPermission('provider_name')">
<el-autocomplete
v-model="form.provider_name"
:fetch-suggestions="querySearchProvider"
@ -171,7 +171,7 @@
@select="handleProviderSelect"
/>
</el-form-item>
<el-form-item label="简介" prop="description">
<el-form-item label="简介" prop="description" v-if="hasFormFieldPermission('description')">
<el-input
v-model="form.description"
type="textarea"
@ -198,6 +198,7 @@ import { ref, reactive, onMounted } from 'vue'
import { InfoFilled, Box, House } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import {
getServiceList,
createService,
@ -212,6 +213,53 @@ import {
type MaterialBaseItem
} from '@/api/inbound/service'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
sku: 'inbound_service:sku',
material_name: 'inbound_service:material_name',
provider_name: 'inbound_service:provider_name',
sale_price: 'inbound_service:sale_price',
description: 'inbound_service:description',
created_at: 'inbound_service:created_at',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// 表单字段权限检查
const hasFormFieldPermission = (fieldName: string) => {
// 超级管理员直接返回true
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// 根据字段名映射到权限码
const map: Record<string, string> = {
base_id: 'inbound_service:base_id',
material_name: 'inbound_service:material_name',
spec_model: 'inbound_service:spec_model',
category: 'inbound_service:category',
material_type: 'inbound_service:material_type',
unit: 'inbound_service:unit',
sale_price: 'inbound_service:sale_price',
provider_name: 'inbound_service:provider_name',
description: 'inbound_service:description',
}
const code = map[fieldName]
if (!code) {
// 没有映射的字段默认显示
return true
}
return userStore.hasPermission(code)
}
// 表格数据
const tableData = ref<ServiceItem[]>([])
const loading = ref(false)

View File

@ -10,12 +10,12 @@
<p class="subtitle">单件自动确认多件弹窗录入</p>
<div class="idle-actions">
<el-button type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="primary" size="large" class="action-btn-full" @click="startNewSession" :loading="btnLoading">
开始新盘点
</el-button>
<el-button
v-if="serverDraftCount > 0"
v-if="serverDraftCount > 0 && userStore.hasPermission('inventory_stocktake:operation')"
type="warning"
plain
size="large"
@ -43,7 +43,7 @@
<el-tag v-else-if="syncStatus === 'syncing'" type="warning" size="small" effect="dark" round>同步中...</el-tag>
<el-tag v-else type="danger" size="small" effect="dark" round>同步失败</el-tag>
</div>
<el-button type="info" text bg size="small" @click="pauseSession" :icon="VideoPause">
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="info" text bg size="small" @click="pauseSession" :icon="VideoPause">
暂停
</el-button>
</div>
@ -84,7 +84,7 @@
</el-button>
</el-col>
<el-col :span="12">
<el-button type="danger" size="large" class="w-100 action-btn" @click="openFinishDialog" :icon="Checked">
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" size="large" class="w-100 action-btn" @click="openFinishDialog" :icon="Checked">
结束盘点
</el-button>
</el-col>
@ -132,6 +132,7 @@
style="width: 100%"
ref="qtyInputRef"
placeholder="请输入实际点数"
class="large-control-input"
/>
<p class="unit-text">单位: {{ currentItem.unit || '个' }}</p>
</div>
@ -139,7 +140,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="showQtyDialog = false">取消</el-button>
<el-button type="primary" @click="handleManualConfirm" size="large">确认数量</el-button>
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="primary" @click="handleManualConfirm" size="large">确认数量</el-button>
</div>
</template>
</el-dialog>
@ -190,6 +191,7 @@
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="scope">
<el-button
v-if="userStore.hasPermission('inventory_stocktake:operation')"
type="primary"
link
icon="Edit"
@ -237,7 +239,7 @@
<el-button @click="showFinishDialog = false">返回修改</el-button>
<div class="footer-right">
<el-button type="success" @click="exportToExcel" :icon="Download">导出Excel</el-button>
<el-button type="danger" @click="finishStocktake" :loading="printing" :icon="Checked">结束</el-button>
<el-button v-if="userStore.hasPermission('inventory_stocktake:operation')" type="danger" @click="finishStocktake" :loading="printing" :icon="Checked">结束</el-button>
</div>
</div>
</template>
@ -276,6 +278,8 @@ interface StockItem {
type?: string
category?: string
price?: number
source_table?: string
stock_id?: number
[key: string]: any
}
@ -292,6 +296,7 @@ const showQtyDialog = ref(false)
const allData = ref<StockItem[]>([])
const scannedMap = ref<Map<string, number>>(new Map())
const borrowedQuantities = ref<Record<string, number>>({})
const filterType = ref('all')
const searchKeyword = ref('')
@ -306,6 +311,34 @@ const api = {
clearDraft: () => request({ url: '/v1/inbound/stock/draft/clear', method: 'post', data: { user_id: currentUser } })
}
const typeToSourceTable = (type: string): string => {
switch (type) {
case 'material': return 'stock_buy'
case 'semi': return 'stock_semi'
case 'product': return 'stock_product'
default: return ''
}
}
async function fetchBorrowedQuantities(items: StockItem[]): Promise<void> {
const payload = items.filter(i => i.source_table && i.stock_id).map(i => ({
source_table: i.source_table,
stock_id: i.stock_id
}))
if (payload.length === 0) return
try {
const res = await request({
url: '/v1/inbound/stock/borrowed-quantities',
method: 'post',
data: { items: payload }
})
// res is map of key->qty
borrowedQuantities.value = { ...borrowedQuantities.value, ...res }
} catch (e) {
console.error('获取借出数量失败', e)
}
}
onMounted(async () => {
await checkServerDraft()
})
@ -380,7 +413,9 @@ const loadData = async () => {
qty_stock: stock,
qty_actual: isScanned ? scannedMap.value.get(uuid)! : 0,
scanned: isScanned,
uniqueKey: `${type}_${item.id}`
uniqueKey: `${type}_${item.id}`,
source_table: typeToSourceTable(type),
stock_id: item.id
})
}
@ -389,6 +424,7 @@ const loadData = async () => {
if (res.products) res.products.forEach((i: any) => processItem(i, 'product'))
allData.value = list
await fetchBorrowedQuantities(list)
} catch (e) {
ElMessage.error('数据加载失败')
} finally { loading.value = false }
@ -470,34 +506,47 @@ const closeOverlays = () => {
const exportToExcel = () => {
try {
// 1. 已盘点 Sheet
const scannedData = allData.value.filter(i => i.scanned).map(item => ({
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '个',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'实盘数量': item.qty_actual,
'盘点结果': item.qty_stock === item.qty_actual ? '相符' : '差异',
'差异数': item.qty_actual - item.qty_stock
}))
const scannedData = allData.value.filter(i => i.scanned).map(item => {
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
const borrowedQty = borrowedQuantities.value[key] || 0
const actualTotal = item.qty_actual + borrowedQty
const diff = actualTotal - item.qty_stock
const result = diff === 0 ? '正常' : diff < 0 ? '盘亏/差异' : '盘盈'
return {
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '个',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'实盘数量': item.qty_actual,
'借出未还数量': borrowedQty,
'盘点结果': result,
'差异数': diff
}
})
// 2. 未盘点 Sheet
const missingData = allData.value.filter(i => !i.scanned).map(item => ({
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'状态': '未盘点'
}))
const missingData = allData.value.filter(i => !i.scanned).map(item => {
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
const borrowedQty = borrowedQuantities.value[key] || 0
return {
'物品名称': item.name,
'类型': item.type || item.material_type || '-',
'类别': item.category || '-',
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
'SKU': item.sku,
'批次/SN': item.serial_number || item.batch_no || '-',
'单位': item.unit || '个',
'单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any),
'借出未还数量': borrowedQty,
'状态': '未盘点'
}
})
const wb = XLSX.utils.book_new()
const ws1 = XLSX.utils.json_to_sheet(scannedData)
@ -506,7 +555,7 @@ const exportToExcel = () => {
const wscols = [
{wch: 20}, {wch: 10}, {wch: 10}, {wch: 15},
{wch: 15}, {wch: 15}, {wch: 5}, {wch: 8},
{wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}
{wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}
]
ws1['!cols'] = wscols
ws2['!cols'] = wscols
@ -690,4 +739,26 @@ const finishStocktake = async () => {
.missing-list-header { font-weight: bold; margin-bottom: 8px; font-size: 13px; border-left: 3px solid #f56c6c; padding-left: 8px; }
.dialog-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.footer-right { display: flex; gap: 10px; }
/* ★★★ 新增:专为平板优化的超大数字输入框样式 ★★★ */
.large-control-input {
height: 60px; /* 增加整体输入框高度 */
}
/* 放大左右两边的加减按钮,并增加点击区域 */
:deep(.large-control-input .el-input-number__decrease),
:deep(.large-control-input .el-input-number__increase) {
width: 70px !important; /* 显著加大按钮宽度 */
font-size: 28px !important; /* 放大加号和减号图标 */
background-color: #f0f2f5; /* 稍微加深背景色,让触控区更明显 */
}
/* 给中间的数字输入区留出左右按钮的空间 */
:deep(.large-control-input .el-input__wrapper) {
padding-left: 80px !important;
padding-right: 80px !important;
}
/* 放大中间数字部分的字体和高度,保持协调 */
:deep(.large-control-input .el-input__inner) {
font-size: 24px !important;
height: 58px !important;
}
</style>

View File

@ -0,0 +1,632 @@
<template>
<div class="app-container">
<el-card class="permission-card" shadow="never">
<template #header>
<div class="card-header">
<div class="header-left">
<div class="title-block">
<span class="main-title">权限配置中心</span>
<span class="sub-title">设定各角色的系统访问级别与数据可见性</span>
</div>
</div>
<el-button
type="primary"
:loading="saving"
@click="handleSave"
:disabled="!currentRole"
size="large"
icon="Check"
class="save-btn"
>
保存配置
</el-button>
</div>
</template>
<div class="main-layout">
<div class="left-panel">
<div class="panel-header">
<el-icon><User /></el-icon> 角色列表
</div>
<el-scrollbar>
<ul class="role-list">
<li
v-for="role in roleList"
:key="role.value"
:class="['role-item', { active: currentRole === role.value }]"
@click="handleRoleSelect(role.value)"
>
<div class="role-icon">
<el-icon v-if="role.value === 'SUPER_ADMIN'"><Avatar /></el-icon>
<el-icon v-else><UserFilled /></el-icon>
</div>
<div class="role-info">
<span class="role-name">{{ role.label }}</span>
<span class="role-code">{{ role.value }}</span>
</div>
<el-icon v-if="currentRole === role.value" class="arrow-icon"><ArrowRight /></el-icon>
</li>
</ul>
</el-scrollbar>
</div>
<div class="right-panel" v-loading="loading">
<div v-if="!currentRole" class="empty-state">
<img src="https://gw.alipayobjects.com/zos/antfincdn/ZHrcdLPrvN/empty.svg" alt="empty" style="width: 200px; opacity: 0.5;" />
<p>请在左侧选择一个角色开始配置</p>
</div>
<div v-else class="config-content">
<div class="panel-header">
<span>权限明细</span>
<el-tag type="warning" effect="plain" round>当前配置: {{ getRoleLabel(currentRole) }}</el-tag>
</div>
<el-table
:data="tableData"
row-key="id"
border
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
class="permission-table"
>
<el-table-column prop="name" label="页面 / 模块" min-width="200">
<template #default="{ row }">
<span style="font-weight: 500;">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column label="访问权限" width="150" align="center">
<template #default="{ row }">
<el-checkbox
v-model="row.hasRead"
@change="(val) => handleReadChange(val, row)"
class="custom-checkbox"
>
<span :class="{ 'text-active': row.hasRead }">可见 (Read)</span>
</el-checkbox>
</template>
</el-table-column>
<el-table-column label="操作权限" width="180" align="center">
<template #default="{ row }">
<div v-if="row.operationCode">
<el-checkbox
v-model="row.hasWrite"
:disabled="!row.hasRead"
@change="(val) => handleWriteChange(val, row)"
class="custom-checkbox"
>
<span :class="{ 'text-active': row.hasWrite, 'text-disabled': !row.hasRead }">可编辑 (Write)</span>
</el-checkbox>
</div>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="字段级控制 (敏感列)" min-width="300">
<template #default="{ row }">
<div v-if="row.elements && row.elements.length > 0">
<el-popover placement="left" :width="300" trigger="click">
<template #reference>
<el-button link type="primary" :disabled="!row.hasRead">
<el-icon style="margin-right: 4px"><Setting /></el-icon>
配置 {{ getCheckedCount(row) }}/{{ row.elements.length }} 个字段
</el-button>
</template>
<div class="field-config-box">
<div class="field-header">
<span>勾选可见字段</span>
<el-checkbox
v-model="row.checkAllElements"
:indeterminate="row.isIndeterminate"
@change="(val) => handleCheckAllElements(val, row)"
size="small"
>全选</el-checkbox>
</div>
<el-checkbox-group v-model="row.checkedElements" @change="(val) => handleCheckedElementsChange(val, row)">
<el-row :gutter="10">
<el-col :span="12" v-for="col in row.elements" :key="col.code">
<el-checkbox :label="col.code" :title="col.name" style="width: 100%; overflow: hidden; text-overflow: ellipsis;">
{{ col.name }}
</el-checkbox>
</el-col>
</el-row>
</el-checkbox-group>
</div>
</el-popover>
<span class="preview-tags">
<span v-for="code in row.checkedElements.slice(0, 2)" :key="code" class="mini-tag">{{ getElementName(row, code) }}</span>
<span v-if="row.checkedElements.length > 2" class="mini-tag">...</span>
</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, UserFilled, ArrowRight, Setting, Check, Avatar } from '@element-plus/icons-vue'
import { getAllPermissionTree, getRolePermissions, saveRolePermissions } from '@/api/system/permission'
// --- 类型定义 ---
interface PermissionNode {
id: number
name: string
code: string
type: string
children?: PermissionNode[]
// 前端辅助字段
hasRead: boolean // 是否勾选了页面
hasWrite: boolean // 是否勾选了 operation
operationCode: string | null // 存储 operation 的 code
elements: any[] // 存储该页面的普通列
checkedElements: string[] // 已勾选的列code
checkAllElements: boolean
isIndeterminate: boolean
}
// --- 状态定义 ---
const loading = ref(false)
const saving = ref(false)
const currentRole = ref('')
const roleList = [
{ label: '超级管理员', value: 'SUPER_ADMIN' },
{ label: '主管', value: 'SUPERVISOR' },
{ label: '财务', value: 'FINANCE' },
{ label: '库管', value: 'WAREHOUSE_MGR' },
{ label: '入库员', value: 'INBOUND' },
{ label: '出库员', value: 'OUTBOUND' },
{ label: '采购员', value: 'PURCHASER' },
{ label: '销售', value: 'SALES' }
]
// 原始树数据
const rawTreeData = ref<any[]>([])
// 表格展示数据 (经过处理)
const tableData = ref<PermissionNode[]>([])
// --- 方法 ---
// 1. 初始化:获取权限树结构
const fetchTree = async () => {
try {
const res: any = await getAllPermissionTree()
if (res.code === 200) {
rawTreeData.value = res.data
// 初始化表格结构(此时没有勾选状态)
tableData.value = transformData(res.data)
}
} catch (e) {
ElMessage.error('加载权限配置失败')
}
}
// ★ 核心:将后端嵌套的 Tree 数据转换为适合表格展示的结构
// 分离 "页面"、"操作列" 和 "普通列"
const transformData = (nodes: any[]): PermissionNode[] => {
return nodes.map(node => {
// 找出所有子节点中的 element
const allChildren = node.children || []
// 1. 找操作列 (作为 Write 权限)
const opNode = allChildren.find((c: any) => c.type === 'element' && (c.code === 'operation' || c.code.endsWith(':operation') || c.code.endsWith('_op')))
const operationCode = opNode ? opNode.code : null
// 2. 找普通列 (作为字段权限)
const columns = allChildren.filter((c: any) => c.type === 'element' && c.code !== 'operation' && !c.code.endsWith('_op') && !c.code.endsWith(':operation'))
// 3. 找子菜单 (如果有)
const subMenus = allChildren.filter((c: any) => c.type === 'menu')
const childrenTransformed = subMenus.length > 0 ? transformData(subMenus) : undefined
return {
id: node.id,
name: node.name,
code: node.code,
type: node.type,
children: childrenTransformed,
// 状态字段初始化
hasRead: false,
hasWrite: false,
operationCode: operationCode,
elements: columns,
checkedElements: [],
checkAllElements: false,
isIndeterminate: false
}
})
}
// 2. 切换角色:回显权限
const handleRoleSelect = async (roleCode: string) => {
currentRole.value = roleCode
loading.value = true
try {
// 获取该角色拥有的所有 code
const res: any = await getRolePermissions(roleCode)
if (res.code === 200) {
const perms = new Set([...(res.data.menus || []), ...(res.data.elements || [])])
// 递归设置表格每一行的状态
setRowStatus(tableData.value, perms)
}
} catch (e) {
ElMessage.error('获取角色权限失败')
} finally {
loading.value = false
}
}
// 递归回显状态
const setRowStatus = (rows: PermissionNode[], perms: Set<any>) => {
rows.forEach(row => {
// 1. 设置可读 (页面Code是否存在)
row.hasRead = perms.has(row.code)
// 2. 设置可编辑 (操作列Code是否存在)
if (row.operationCode) {
row.hasWrite = perms.has(row.operationCode)
}
// 3. 设置已选字段
if (row.elements && row.elements.length > 0) {
row.checkedElements = row.elements
.filter(el => perms.has(el.code))
.map(el => el.code)
updateCheckAllStatus(row)
}
// 4. 递归子菜单
if (row.children) {
setRowStatus(row.children, perms)
}
})
}
// --- 交互逻辑 ---
// 当“可读”改变
const handleReadChange = (val: boolean, row: PermissionNode) => {
// 如果关闭可读,强制关闭可编辑,并清空字段选择
if (!val) {
row.hasWrite = false
// row.checkedElements = [] // 可选:是否同时也清空字段勾选?通常建议保留,方便误触恢复,但这里为了逻辑严谨先不清空,只在保存时过滤
} else {
// 如果开启可读,默认全选字段 (提升体验)
// row.checkedElements = row.elements.map(e => e.code)
// updateCheckAllStatus(row)
// 【新增功能】如果没有字段级控制,且存在操作权限,则自动联动勾选可编辑
if ((!row.elements || row.elements.length === 0) && row.operationCode) {
row.hasWrite = true
}
}
// 联动子菜单:如果父级关闭,子级是否关闭?通常不强制,但可以做
}
// 当“可编辑”改变
const handleWriteChange = (val: boolean, row: PermissionNode) => {
// 如果开启编辑,必须开启可读
if (val && !row.hasRead) {
row.hasRead = true
}
}
// 字段全选逻辑
const handleCheckAllElements = (val: boolean, row: PermissionNode) => {
row.checkedElements = val ? row.elements.map(e => e.code) : []
row.isIndeterminate = false
}
// 字段单选逻辑
const handleCheckedElementsChange = (val: string[], row: PermissionNode) => {
updateCheckAllStatus(row)
}
const updateCheckAllStatus = (row: PermissionNode) => {
const checkedCount = row.checkedElements.length
row.checkAllElements = checkedCount === row.elements.length && row.elements.length > 0
row.isIndeterminate = checkedCount > 0 && checkedCount < row.elements.length
}
// --- 保存逻辑 ---
const handleSave = async () => {
if (!currentRole.value) return
const permissions: string[] = []
// 递归收集所有状态为 true 的 code
const collectPermissions = (rows: PermissionNode[]) => {
rows.forEach(row => {
// 1. 页面权限
if (row.hasRead) {
permissions.push(row.code)
}
// 2. 操作权限 (只有当页面可读时才有效)
if (row.hasRead && row.hasWrite && row.operationCode) {
permissions.push(row.operationCode)
}
// 3. 字段权限 (只有当页面可读时才有效)
if (row.hasRead && row.checkedElements.length > 0) {
permissions.push(...row.checkedElements)
}
// 递归
if (row.children) {
collectPermissions(row.children)
}
})
}
collectPermissions(tableData.value)
saving.value = true
try {
const res: any = await saveRolePermissions({
role_code: currentRole.value,
permissions: permissions
})
if (res.code === 200) {
ElMessage.success('权限保存成功!')
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
// --- 辅助 ---
const getRoleLabel = (val: string) => {
const found = roleList.find(r => r.value === val)
return found ? found.label : val
}
const getCheckedCount = (row: PermissionNode) => row.checkedElements.length
const getElementName = (row: PermissionNode, code: string) => {
const found = row.elements.find(e => e.code === code)
return found ? found.name : code
}
onMounted(() => {
fetchTree()
})
</script>
<style scoped>
.app-container {
padding: 15px;
height: calc(100vh - 84px);
background-color: #f0f2f5;
}
.permission-card {
height: 100%;
display: flex;
flex-direction: column;
border: none;
border-radius: 8px;
}
:deep(.el-card__header) {
padding: 15px 20px;
border-bottom: 1px solid #f0f0f0;
}
:deep(.el-card__body) {
flex: 1;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title-block {
display: flex;
flex-direction: column;
}
.main-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.sub-title {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
/* 布局 */
.main-layout {
display: flex;
flex: 1;
height: 100%;
}
/* 左侧样式 */
.left-panel {
width: 260px;
background: #fff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 15px 20px;
font-size: 14px;
font-weight: 600;
color: #606266;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 8px;
}
.role-list {
list-style: none;
padding: 10px;
margin: 0;
}
.role-item {
display: flex;
align-items: center;
padding: 12px 15px;
margin-bottom: 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
color: #606266;
}
.role-item:hover {
background-color: #f5f7fa;
}
.role-item.active {
background-color: #e6f7ff;
color: #1890ff;
font-weight: 500;
}
.role-icon {
margin-right: 12px;
font-size: 18px;
display: flex;
align-items: center;
}
.role-info {
flex: 1;
display: flex;
flex-direction: column;
}
.role-name {
font-size: 14px;
}
.role-code {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.arrow-icon {
font-size: 14px;
}
/* 右侧样式 */
.right-panel {
flex: 1;
background: #fff;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #909399;
font-size: 14px;
}
.config-content {
height: 100%;
display: flex;
flex-direction: column;
}
.config-content .panel-header {
background: #fff;
padding: 0 0 15px 0;
justify-content: space-between;
}
.permission-table {
flex: 1;
overflow: auto;
}
/* 表格内样式 */
.custom-checkbox {
height: auto;
}
.text-active {
color: #1890ff;
font-weight: 500;
}
.text-disabled {
color: #c0c4cc;
}
.text-gray {
color: #d9d9d9;
}
/* 字段配置 Popover 样式 */
.field-config-box {
padding: 5px;
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 10px;
font-weight: bold;
}
.preview-tags {
display: inline-flex;
align-items: center;
margin-left: 8px;
gap: 4px;
}
.mini-tag {
background: #f5f5f5;
color: #909399;
font-size: 11px;
padding: 1px 4px;
border-radius: 3px;
}
</style>

View File

@ -4,7 +4,7 @@
<template #header>
<div class="card-header">
<span style="font-weight: bold;">员工账号管理</span>
<el-button type="primary" @click="handleCreate">
<el-button v-if="userStore.hasPermission('system_user:operation')" type="primary" @click="handleCreate">
+ 新增员工
</el-button>
</div>
@ -16,23 +16,33 @@
border
style="width: 100%"
>
<el-table-column prop="username" label="用户标识" min-width="180" />
<el-table-column v-if="hasColumnPermission('username')" prop="username" label="用户标识" min-width="180" />
<el-table-column prop="department" label="所属部门" width="150">
<el-table-column v-if="hasColumnPermission('department')" prop="department" label="所属部门" width="150">
<template #default="scope">
<el-tag>{{ scope.row.department }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="role" label="系统角色" width="180">
<el-table-column v-if="hasColumnPermission('role')" prop="role" label="系统角色" width="180">
<template #default="scope">
{{ formatRole(scope.row.role) }}
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column v-if="hasColumnPermission('email')" prop="email" label="邮箱" min-width="200" />
<el-table-column label="操作" width="180" fixed="right">
<el-table-column v-if="hasColumnPermission('status')" prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'active' ? 'success' : 'danger'">
{{ scope.row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('created_at')" prop="created_at" label="创建时间" width="160" />
<el-table-column v-if="userStore.hasPermission('system_user:operation')" label="操作" width="180" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-popconfirm title="确定要删除该用户吗?" @confirm="handleDelete(scope.row)">
@ -57,20 +67,20 @@
:rules="rules"
label-width="100px"
>
<el-form-item label="真实姓名" prop="cn_name">
<el-form-item label="真实姓名" prop="cn_name" v-if="hasFormFieldPermission('cn_name')">
<el-input
v-model="form.cn_name"
placeholder="请输入中文姓名 (如: 张三)"
:disabled="isEdit"
:disabled="isEdit || !userStore.hasPermission('system_user:operation')"
@input="handleNameInput"
/>
</el-form-item>
<el-form-item label="登录账号" prop="username">
<el-form-item label="登录账号" prop="username" v-if="hasFormFieldPermission('username')">
<el-input
v-model="form.username"
placeholder="自动生成,可修改 (如: zhangsan)"
:disabled="isEdit"
:disabled="isEdit || !userStore.hasPermission('system_user:operation')"
>
<template #append>
<span v-if="!isEdit" style="font-size: 12px; color: #999;">重复自动+1</span>
@ -78,16 +88,17 @@
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-form-item label="密码" prop="password" v-if="hasFormFieldPermission('password')">
<el-input
v-model="form.password"
type="password"
show-password
:placeholder="isEdit ? '不修改请留空' : '设置初始密码'"
:disabled="!userStore.hasPermission('system_user:operation')"
/>
</el-form-item>
<el-form-item label="所属部门" prop="department">
<el-form-item label="所属部门" prop="department" v-if="hasFormFieldPermission('department')">
<el-select
v-model="form.department"
placeholder="请输入或选择部门"
@ -95,13 +106,14 @@
filterable
allow-create
default-first-option
:disabled="!userStore.hasPermission('system_user:operation')"
>
<el-option v-for="item in departmentOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="系统角色" prop="role">
<el-select v-model="form.role" placeholder="授予权限" style="width: 100%">
<el-form-item label="系统角色" prop="role" v-if="hasFormFieldPermission('role')">
<el-select v-model="form.role" placeholder="授予权限" style="width: 100%" :disabled="!userStore.hasPermission('system_user:operation')">
<el-option
v-for="option in roleOptions"
:key="option.value"
@ -111,15 +123,15 @@
</el-select>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
<el-form-item label="邮箱" prop="email" v-if="hasFormFieldPermission('email')">
<el-input v-model="form.email" placeholder="请输入邮箱" :disabled="!userStore.hasPermission('system_user:operation')" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :loading="submitLoading">
<el-button v-if="userStore.hasPermission('system_user:operation')" type="primary" @click="onSubmit" :loading="submitLoading">
{{ isEdit ? '确认修改' : '确认创建' }}
</el-button>
</div>
@ -136,6 +148,37 @@ import { ElMessage } from 'element-plus'
import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
username: 'system_user:username',
department: 'system_user:department',
role: 'system_user:role',
email: 'system_user:email',
status: 'system_user:status',
created_at: 'system_user:created_at',
// 表单字段
cn_name: 'system_user:username',
password: 'system_user:password',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// 检查表单字段权限
const hasFormFieldPermission = (fieldName: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[fieldName]
return code ? userStore.hasPermission(code) : false
}
const tableLoading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
@ -241,6 +284,7 @@ const getList = async () => {
tableData.value = res.data || []
extractDepartments(tableData.value)
} catch (error) {
// 错误已由全局拦截器统一处理
console.error('Fetch users failed:', error)
} finally {
tableLoading.value = false
@ -317,7 +361,7 @@ const onSubmit = async () => {
dialogVisible.value = false
getList()
} catch (error) {
// request 拦截器会处理错误
// 错误已由全局拦截器统一处理
} finally {
submitLoading.value = false
}
@ -342,6 +386,7 @@ const handleDelete = async (row: any) => {
ElMessage.success('删除成功')
getList()
} catch (error) {
// 错误已由全局拦截器统一处理
}
}
@ -378,4 +423,4 @@ onMounted(() => {
text-align: right;
margin-top: 20px;
}
</style>
</style>

View File

@ -13,10 +13,14 @@
</template>
<div class="scan-section">
<div class="camera-placeholder" @click="showCamera = true">
<div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
@ -26,12 +30,13 @@
clearable
ref="barcodeRef"
size="large"
:disabled="!userStore.hasPermission('op_borrow:operation')"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput">添加</el-button>
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('op_borrow:operation')">添加</el-button>
</template>
</el-input>
</div>
@ -40,16 +45,16 @@
<div class="cart-section">
<div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('name')" prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="可用库存" width="90" align="center">
<el-table-column v-if="hasColumnPermission('available_quantity')" label="可用库存" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="借用数" width="130" align="center">
<el-table-column v-if="hasColumnPermission('out_quantity')" label="借用数" width="130" align="center">
<template #default="{row}">
<el-input-number
v-model="row.out_quantity"
@ -57,11 +62,12 @@
:max="parseFloat(row.available_quantity)"
size="small"
style="width: 100px"
:disabled="!userStore.hasPermission('op_borrow:operation')"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<el-table-column v-if="userStore.hasPermission('op_borrow:operation')" label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
@ -102,7 +108,7 @@
</el-form-item>
<el-form-item label="领用人签名确认" required>
<div class="signature-box" @click="openSignatureDialog">
<div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('op_borrow:operation')">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
@ -112,11 +118,17 @@
<span>点击此处进行全屏签名</span>
</div>
</div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
<el-button v-if="userStore.hasPermission('op_borrow:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="userStore.hasPermission('op_borrow:operation')" type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出
</el-button>
</div>
@ -187,6 +199,27 @@ import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode } from '@/api/outbound'
import request from '@/utils/request'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrower_name: 'op_borrow:borrower_name',
sku: 'op_borrow:sku',
available_quantity: 'op_borrow:available_quantity',
out_quantity: 'op_borrow:out_quantity',
// 其他字段可根据需要添加
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// --- 状态定义 ---
const barcodeInput = ref('')
@ -564,4 +597,4 @@ onUnmounted(() => {
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>
</style>

View File

@ -19,12 +19,12 @@
v-loading="loading"
:row-class-name="tableRowClassName"
>
<el-table-column prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column prop="borrower_name" label="借用人" width="100" />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column label="归还时间 / 预计" min-width="200">
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
<template #default="{row}">
<div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag>
@ -40,7 +40,7 @@
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{row}">
<el-tag :type="row.status==='returned'?'success':'warning'">
{{ row.status==='returned'?'已还':'借出中' }}
@ -48,22 +48,22 @@
</template>
</el-table-column>
<el-table-column label="归还库位" min-width="120">
<el-table-column v-if="hasColumnPermission('return_location')" label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="电子签名" width="140" align="center">
<el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" label="电子签名" width="140" align="center">
<template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature" width="220">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature && hasColumnPermission('borrow_signature')" width="220">
<template #reference><el-tag size="small"></el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
<el-popover trigger="hover" placement="top" v-if="row.return_signature" width="220">
<el-popover trigger="hover" placement="top" v-if="row.return_signature && hasColumnPermission('return_signature')" width="220">
<template #reference><el-tag type="success" size="small"></el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
@ -88,6 +88,32 @@ import request from '@/utils/request'
import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date
import 'dayjs/locale/zh-cn' // 导入中文包
dayjs.locale('zh-cn')
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrow_no: 'op_records:borrow_no',
borrower_name: 'op_records:borrower_name',
sku: 'op_records:sku',
borrow_time: 'op_records:borrow_time',
return_time: 'op_records:return_time',
status: 'op_records:status',
expected_return_time: 'op_records:expected_return_time',
return_location: 'op_records:return_location',
borrow_signature: 'op_records:borrow_signature',
return_signature: 'op_records:return_signature',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
const list = ref<any[]>([])
const total = ref(0)
@ -195,4 +221,4 @@ onMounted(fetchData)
.text-normal {
color: #909399;
}
</style>
</style>

View File

@ -13,10 +13,14 @@
</template>
<div class="scan-section">
<div class="camera-placeholder" @click="showCamera = true">
<div v-if="userStore.hasPermission('op_return:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
@ -26,12 +30,13 @@
clearable
ref="barcodeRef"
size="large"
:disabled="!userStore.hasPermission('op_return:operation')"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="scanItem">识别</el-button>
<el-button @click="scanItem" :disabled="!userStore.hasPermission('op_return:operation')">识别</el-button>
</template>
</el-input>
</div>
@ -40,16 +45,17 @@
<div class="cart-section">
<div v-if="returnList.length > 0">
<el-table :data="returnList" border stripe style="width: 100%">
<el-table-column prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="归还库位(可改)" min-width="160">
<el-table-column v-if="hasColumnPermission('return_location')" label="归还库位(可改)" min-width="160">
<template #default="{row}">
<el-input
v-model="row.return_location"
:placeholder="`原: ${row.current_location || '无'}`"
clearable
size="small"
:disabled="!userStore.hasPermission('op_return:operation')"
>
<template #append v-if="row.return_location !== row.current_location">
<span style="color: #E6A23C; font-size: 12px;">变更</span>
@ -58,7 +64,7 @@
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<el-table-column v-if="userStore.hasPermission('op_return:operation')" label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" />
</template>
@ -77,7 +83,7 @@
<el-form label-position="top">
<el-form-item required>
<div class="signature-box" @click="openSignatureDialog">
<div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('op_return:operation')">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
@ -87,12 +93,18 @@
<span>点击此处进行库管签名</span>
</div>
</div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item>
</el-form>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select">
<el-button v-if="userStore.hasPermission('op_return:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="userStore.hasPermission('op_return:operation')" type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select">
确认归还
</el-button>
</div>
@ -161,6 +173,25 @@ import { uploadFile } from '@/api/common/upload'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
borrower_name: 'op_return:borrower_name',
sku: 'op_return:sku',
return_location: 'op_return:return_location',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// --- 状态 ---
const barcode = ref('')
@ -507,4 +538,4 @@ onUnmounted(() => {
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>
</style>