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 - 借还/维修/报废) # 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
# ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★
# ----------------------------------------------------- # -----------------------------------------------------
try: try:
from app.api.v1.transactions import trans_bp 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') app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy')
print("✅ Transactions 模块注册成功") print("✅ Transactions 模块注册成功")
except ImportError as e: except ImportError as e:
# 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题 print(f"⚠️ 提示: Transaction 模块导入失败: {e}")
print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {e}")
# ----------------------------------------------------- # -----------------------------------------------------
# 2.5 注册出库模块 (Outbound) # 2.5 注册出库模块 (Outbound)
@ -119,6 +117,19 @@ def create_app():
except ImportError as e: except ImportError as e:
print(f"❌ 错误: BOM 模块导入失败: {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. 预加载数据模型 # 3. 预加载数据模型
# ========================================================= # =========================================================
@ -133,8 +144,8 @@ def create_app():
# 出库模型 # 出库模型
from app.models.outbound import TransOutbound from app.models.outbound import TransOutbound
# 系统与业务模型 # 系统与业务模型 (SysRolePermission 等在 models.system 中)
from app.models.system import SysUser, SysLog from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission
# 确保借还模型被加载 # 确保借还模型被加载
from app.models.transaction import TransBorrow, TransRepair, TransScrap from app.models.transaction import TransBorrow, TransRepair, TransScrap

View File

@ -2,10 +2,56 @@
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt from flask_jwt_extended import jwt_required, get_jwt
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.utils.decorators import permission_required
auth_bp = Blueprint('auth', __name__) 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']) @auth_bp.route('/login', methods=['POST'])
def login(): def login():
try: try:
@ -34,9 +80,36 @@ def login():
@auth_bp.route('/user/create', methods=['POST']) @auth_bp.route('/user/create', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('system_user:operation')
def create_user(): def create_user():
try: try:
data = request.get_json() 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() claims = get_jwt()
operator_role = claims.get('role') operator_role = claims.get('role')
@ -51,9 +124,34 @@ def create_user():
# [新增] 更新用户 # [新增] 更新用户
@auth_bp.route('/user/<int:user_id>', methods=['PUT']) @auth_bp.route('/user/<int:user_id>', methods=['PUT'])
@jwt_required() @jwt_required()
@permission_required('system_user:operation')
def update_user(user_id): def update_user(user_id):
try: try:
data = request.get_json() 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() claims = get_jwt()
operator_role = claims.get('role') operator_role = claims.get('role')
@ -67,10 +165,14 @@ def update_user(user_id):
@auth_bp.route('/users', methods=['GET']) @auth_bp.route('/users', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('system_user')
def get_users(): def get_users():
try: try:
users = AuthService.get_all_users() 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: except Exception as e:
current_app.logger.error(f"Get Users Failed: {str(e)}") current_app.logger.error(f"Get Users Failed: {str(e)}")
return jsonify({'msg': '获取用户列表失败'}), 500 return jsonify({'msg': '获取用户列表失败'}), 500
@ -78,6 +180,7 @@ def get_users():
@auth_bp.route('/user/<int:user_id>', methods=['DELETE']) @auth_bp.route('/user/<int:user_id>', methods=['DELETE'])
@jwt_required() @jwt_required()
@permission_required('system_user:operation')
def delete_user(user_id): def delete_user(user_id):
try: try:
claims = get_jwt() claims = get_jwt()
@ -88,3 +191,20 @@ def delete_user(user_id):
except Exception as e: except Exception as e:
current_app.logger.error(f"Delete User Failed: {str(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.base import MaterialBase
from app.models.bom import BomTable from app.models.bom import BomTable
from app.extensions import db 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__) 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 接口(基于 bom_no ====================
@bom_bp.route('/list', methods=['GET']) @bom_bp.route('/list', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom_list(): def get_bom_list():
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤""" """获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
try: try:
@ -20,6 +66,10 @@ def get_bom_list():
active_only = request.args.get('active_only', 'false').lower() == 'true' active_only = request.args.get('active_only', 'false').lower() == 'true'
data = BomService.get_bom_list(keyword=keyword, active_only=active_only) 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({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -30,8 +80,9 @@ def get_bom_list():
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 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() @jwt_required()
@permission_required('bom_manage')
def get_bom_detail(bom_no): def get_bom_detail(bom_no):
""" """
根据 BOM 编号获取配方详情 根据 BOM 编号获取配方详情
@ -42,6 +93,9 @@ def get_bom_detail(bom_no):
data = BomService.get_bom_detail(bom_no, version=version) data = BomService.get_bom_detail(bom_no, version=version)
if not data: if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -54,10 +108,41 @@ def get_bom_detail(bom_no):
@bom_bp.route('/save', methods=['POST']) @bom_bp.route('/save', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation')
def save_bom(): def save_bom():
"""保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)""" """保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)"""
try: try:
req_data = request.get_json() 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: if 'parent_id' not in req_data or 'children' not in req_data:
return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400 return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400
@ -79,14 +164,18 @@ def save_bom():
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 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() @jwt_required()
@permission_required('bom_manage')
def get_bom_with_stock_by_no(bom_no): def get_bom_with_stock_by_no(bom_no):
"""根据 BOM 编号获取配方详情及库存信息""" """根据 BOM 编号获取配方详情及库存信息"""
try: try:
data = BomService.get_bom_with_stock_by_bom_no(bom_no) data = BomService.get_bom_with_stock_by_bom_no(bom_no)
if not data: if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -99,8 +188,9 @@ def get_bom_with_stock_by_no(bom_no):
# ==================== 删除BOM接口 ==================== # ==================== 删除BOM接口 ====================
@bom_bp.route('/<bom_no>', methods=['DELETE']) @bom_bp.route('/<path:bom_no>', methods=['DELETE'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation')
def delete_bom(bom_no): def delete_bom(bom_no):
""" """
根据 BOM 编号删除 根据 BOM 编号删除
@ -133,9 +223,13 @@ def delete_bom(bom_no):
@bom_bp.route('/<int:parent_id>', methods=['GET']) @bom_bp.route('/<int:parent_id>', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom(parent_id): def get_bom(parent_id):
try: try:
data = BomService.get_bom_with_stock(parent_id) data = BomService.get_bom_with_stock(parent_id)
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -148,9 +242,40 @@ def get_bom(parent_id):
@bom_bp.route('', methods=['POST']) @bom_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('bom_manage:operation')
def save_bom_legacy(): def save_bom_legacy():
try: try:
req_data = request.get_json() 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') parent_id = req_data.get('parent_id')
child_list = req_data.get('children', []) child_list = req_data.get('children', [])
if not parent_id or not isinstance(child_list, list): 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']) @bom_bp.route('/base/list', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_material_base_list(): def get_material_base_list():
"""获取所有基础物料列表,用于前端下拉框""" """获取所有基础物料列表,用于前端下拉框"""
try: try:
materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all() materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all()
data = [item.to_dict() for item in materials] data = [item.to_dict() for item in materials]
# 字段级脱敏 (如果需要,但此接口通常用于下拉选择,可能不需要脱敏)
# 保持原样
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -186,12 +314,16 @@ def get_material_base_list():
@bom_bp.route('/parents', methods=['GET']) @bom_bp.route('/parents', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('bom_manage')
def get_bom_parents(): def get_bom_parents():
"""获取所有已定义BOM的父件物料列表兼容旧版""" """获取所有已定义BOM的父件物料列表兼容旧版"""
try: try:
subq = db.session.query(BomTable.parent_id).distinct().subquery() subq = db.session.query(BomTable.parent_id).distinct().subquery()
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all() parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
data = [item.to_dict() for item in parents] 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({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',

View File

@ -1,22 +1,95 @@
# 文件路径: app/api/v1/inbound/base.py # 文件路径: 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.services.inbound.base_service import MaterialBaseService
from app.utils.decorators import login_required, permission_required
import traceback import traceback
import datetime import datetime
inbound_base_bp = Blueprint('stock_base', __name__) 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) # 1. 搜索接口 (GET /api/v1/inbound/base/search)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/search', methods=['GET']) @inbound_base_bp.route('/search', methods=['GET'])
@permission_required('material_list')
def search_base(): def search_base():
try: try:
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = MaterialBaseService.search_material(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: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
@ -26,6 +99,7 @@ def search_base():
# 2. 列表接口 (GET /api/v1/inbound/base/list) # 2. 列表接口 (GET /api/v1/inbound/base/list)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/list', methods=['GET']) @inbound_base_bp.route('/list', methods=['GET'])
@permission_required('material_list')
def get_list(): def get_list():
try: try:
page = request.args.get('pageNum', 1, type=int) page = request.args.get('pageNum', 1, type=int)
@ -37,10 +111,16 @@ def get_list():
'company': request.args.get('company', ''), 'company': request.args.get('company', ''),
'category': request.args.get('category', ''), 'category': request.args.get('category', ''),
'type': request.args.get('type', ''), '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) 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}) return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
@ -51,6 +131,7 @@ def get_list():
# 2.1 选项接口 (GET /api/v1/inbound/base/options) # 2.1 选项接口 (GET /api/v1/inbound/base/options)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/options', methods=['GET']) @inbound_base_bp.route('/options', methods=['GET'])
@permission_required('material_list')
def get_options(): def get_options():
try: try:
data = MaterialBaseService.get_distinct_options() data = MaterialBaseService.get_distinct_options()
@ -64,6 +145,7 @@ def get_options():
# 2.2 导出接口 (GET /api/v1/inbound/base/export) # 2.2 导出接口 (GET /api/v1/inbound/base/export)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/export', methods=['GET']) @inbound_base_bp.route('/export', methods=['GET'])
@permission_required('material_list')
def export_data(): def export_data():
try: try:
# 获取筛选条件 # 获取筛选条件
@ -75,8 +157,11 @@ def export_data():
'isEnabled': request.args.get('isEnabled', None) '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)
# 简单处理UTC时间 + 8小时 # 简单处理UTC时间 + 8小时
@ -101,13 +186,48 @@ def export_data():
# 3. 新增接口 (POST /api/v1/inbound/base/) # 3. 新增接口 (POST /api/v1/inbound/base/)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/', methods=['POST']) @inbound_base_bp.route('/', methods=['POST'])
@permission_required('material_list:operation')
def create(): def create():
try: try:
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400 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": "新增成功"}) return jsonify({"code": 200, "msg": "新增成功"})
except ValueError as e: except ValueError as e:
# 捕获业务逻辑验证错误 (如名称为空) # 捕获业务逻辑验证错误 (如名称为空)
@ -122,10 +242,45 @@ def create():
# 4. 修改接口 (PUT /api/v1/inbound/base/<id>) # 4. 修改接口 (PUT /api/v1/inbound/base/<id>)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['PUT']) @inbound_base_bp.route('/<int:id>', methods=['PUT'])
@permission_required('material_list:operation')
def update(id): def update(id):
try: try:
data = request.get_json() 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": "修改成功"}) return jsonify({"code": 200, "msg": "修改成功"})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
@ -136,6 +291,7 @@ def update(id):
# 5. 删除接口 (DELETE /api/v1/inbound/base/<id>) # 5. 删除接口 (DELETE /api/v1/inbound/base/<id>)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['DELETE']) @inbound_base_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('material_list:operation')
def delete(id): def delete(id):
try: try:
MaterialBaseService.delete_material(id) MaterialBaseService.delete_material(id)

View File

@ -1,14 +1,90 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.buy_service import BuyInboundService from app.services.inbound.buy_service import BuyInboundService
from app.utils.decorators import permission_required
import traceback import traceback
inbound_buy_bp = Blueprint('stock_buy', __name__) 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. 基础物料搜索 # 0. 基础物料搜索
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/search-base', methods=['GET']) @inbound_buy_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_buy')
def search_base(): def search_base():
try: try:
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
@ -33,6 +109,7 @@ def search_base():
# 1. 获取列表 (修改:接收 category 和 material_type) # 1. 获取列表 (修改:接收 category 和 material_type)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/list', methods=['GET']) @inbound_buy_bp.route('/list', methods=['GET'])
@permission_required('inbound_buy')
def get_list(): def get_list():
try: try:
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@ -42,12 +119,17 @@ def get_list():
# 新增筛选参数 # 新增筛选参数
category = request.args.get('category', '') category = request.args.get('category', '')
material_type = request.args.get('material_type', '') material_type = request.args.get('material_type', '')
company = request.args.get('company', '')
# 状态参数处理 # 状态参数处理
statuses_str = request.args.get('statuses', '') statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else [] 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}) return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
@ -58,12 +140,59 @@ def get_list():
# 2. 新增入库 # 2. 新增入库
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/submit', methods=['POST']) @inbound_buy_bp.route('/submit', methods=['POST'])
@permission_required('inbound_buy:operation')
def submit(): def submit():
try: try:
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({"code": 400, "msg": "No data"}), 400 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) new_stock = BuyInboundService.handle_inbound(data)
return jsonify({ return jsonify({
@ -80,9 +209,55 @@ def submit():
# 3. 更新入库 # 3. 更新入库
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['PUT']) @inbound_buy_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_buy:operation')
def update_buy(id): def update_buy(id):
try: try:
data = request.get_json() 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) BuyInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"}) return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e: except Exception as e:
@ -93,6 +268,7 @@ def update_buy(id):
# 4. 删除 # 4. 删除
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['DELETE']) @inbound_buy_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_buy:operation')
def delete_buy(id): def delete_buy(id):
try: try:
BuyInboundService.delete_inbound(id) BuyInboundService.delete_inbound(id)
@ -105,6 +281,7 @@ def delete_buy(id):
# 5. [新增] 获取筛选下拉选项 (修复404的关键) # 5. [新增] 获取筛选下拉选项 (修复404的关键)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/options', methods=['GET']) @inbound_buy_bp.route('/options', methods=['GET'])
@permission_required('inbound_buy')
def get_options(): def get_options():
try: try:
data = BuyInboundService.get_filter_options() data = BuyInboundService.get_filter_options()
@ -117,6 +294,7 @@ def get_options():
# 6. 获取关联的出库历史 (如果有) # 6. 获取关联的出库历史 (如果有)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>/history', methods=['GET']) @inbound_buy_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_buy')
def get_history(id): def get_history(id):
# 如果没有出库模块,这个接口可能为空,但为保持兼容性保留 # 如果没有出库模块,这个接口可能为空,但为保持兼容性保留
return jsonify({"code": 200, "msg": "success", "data": []}) return jsonify({"code": 200, "msg": "success", "data": []})
@ -126,6 +304,7 @@ def get_history(id):
# 7. 供应商建议 # 7. 供应商建议
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/suppliers', methods=['GET']) @inbound_buy_bp.route('/suggestions/suppliers', methods=['GET'])
@permission_required('inbound_buy')
def get_supplier_suggestions(): def get_supplier_suggestions():
base_id = request.args.get('base_id', type=int) base_id = request.args.get('base_id', type=int)
if not base_id: if not base_id:
@ -138,6 +317,7 @@ def get_supplier_suggestions():
# 8. 采购人建议 (全局) # 8. 采购人建议 (全局)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/users', methods=['GET']) @inbound_buy_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_buy')
def get_user_suggestions(): def get_user_suggestions():
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = BuyInboundService.get_history_purchasers(keyword) data = BuyInboundService.get_history_purchasers(keyword)
@ -148,6 +328,7 @@ def get_user_suggestions():
# 9. 链接建议 # 9. 链接建议
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/links', methods=['GET']) @inbound_buy_bp.route('/suggestions/links', methods=['GET'])
@permission_required('inbound_buy')
def get_link_suggestions(): def get_link_suggestions():
base_id = request.args.get('base_id', type=int) base_id = request.args.get('base_id', type=int)
link_type = request.args.get('type', 'original') # original or detail link_type = request.args.get('type', 'original') # original or detail
@ -161,6 +342,7 @@ def get_link_suggestions():
# 10. 库位建议 # 10. 库位建议
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/suggestions/locations', methods=['GET']) @inbound_buy_bp.route('/suggestions/locations', methods=['GET'])
@permission_required('inbound_buy')
def get_location_suggestions(): def get_location_suggestions():
base_id = request.args.get('base_id', type=int) base_id = request.args.get('base_id', type=int)
if not base_id: if not base_id:

View File

@ -1,109 +1,129 @@
# inventory-backend/app/api/v1/inbound/product.py # inventory-backend/app/api/v1/inbound/product.py
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService from app.services.inbound.product_service import ProductInboundService
from app.utils.decorators import permission_required
import traceback import traceback
# === 这一行非常关键,绝对不能丢!===
inbound_product_bp = Blueprint('stock_product', __name__) 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']) @inbound_product_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_product')
def search_base(): def search_base():
"""
对应前端 API: /inbound/product/search-base
功能: 模糊搜索基础物料,返回 spec, unit, category, type 等详细信息
"""
try: try:
keyword = request.args.get('keyword', '') 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) page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int) result = ProductInboundService.search_base_material(keyword, page)
keyword = request.args.get('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']]
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = ProductInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result}) return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_product_bp.route('/search-bom', methods=['GET'])
# ------------------------------------------------------------------ @permission_required('inbound_product')
# 2. 新增入库 def search_bom():
# ------------------------------------------------------------------
@inbound_product_bp.route('/submit', methods=['POST'])
def submit():
try: try:
# 调用 Service 处理入库,获取新创建的对象 keyword = request.args.get('keyword', '')
new_stock = ProductInboundService.handle_inbound(request.get_json()) data = ProductInboundService.search_bom_options(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
# 返回成功信息以及新创建的数据包含生成的ID和SKU供前端自动打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 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']) @inbound_product_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_product:operation')
def update(id): def update(id):
try: 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": "更新成功"}) return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>', methods=['DELETE']) @inbound_product_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_product:operation')
def delete(id): def delete(id):
try: try:
ProductInboundService.delete_inbound(id) ProductInboundService.delete_inbound(id)
@ -112,11 +132,8 @@ def delete(id):
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. 获取出库历史
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>/history', methods=['GET']) @inbound_product_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_product')
def get_history(id): def get_history(id):
try: try:
data = ProductInboundService.get_outbound_history(id) data = ProductInboundService.get_outbound_history(id)
@ -125,24 +142,29 @@ def get_history(id):
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 6. 系统用户建议
# ------------------------------------------------------------------
@inbound_product_bp.route('/suggestions/users', methods=['GET']) @inbound_product_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_product')
def get_user_suggestions(): def get_user_suggestions():
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = ProductInboundService.search_system_users(keyword) data = ProductInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 7. 获取筛选选项
# ------------------------------------------------------------------
@inbound_product_bp.route('/options', methods=['GET']) @inbound_product_bp.route('/options', methods=['GET'])
@permission_required('inbound_product')
def get_options(): def get_options():
try: try:
data = ProductInboundService.get_filter_options() data = ProductInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500 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 # inventory-backend/app/api/v1/inbound/semi.py
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService from app.services.inbound.semi_service import SemiInboundService
from app.utils.decorators import permission_required
import traceback import traceback
# 定义蓝图 # === 这一行非常关键,绝对不能丢!===
inbound_semi_bp = Blueprint('stock_semi', __name__) 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']) @inbound_semi_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_semi')
def search_base(): def search_base():
"""
供前端下拉框远程搜索使用 (搜索半成品类型的基础物料)
Query Param: keyword (名称或规格)
"""
try: try:
keyword = request.args.get('keyword', '') 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) page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int) result = SemiInboundService.search_base_material(keyword, page)
# 支持按关键字搜索BOM号、工单号、SN、批号等 user_permissions = get_current_user_permissions()
keyword = request.args.get('keyword', '') if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
# [修改] 获取状态列表参数
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = SemiInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result}) return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/search-bom', methods=['GET'])
# ------------------------------------------------------------------ @permission_required('inbound_semi')
# 2. 新增半成品入库 (修改:返回创建的对象数据) def search_bom():
# ------------------------------------------------------------------
@inbound_semi_bp.route('/submit', methods=['POST'])
def submit():
try: try:
data = request.get_json() keyword = request.args.get('keyword', '')
if not data: data = SemiInboundService.search_bom_options(keyword)
return jsonify({"code": 400, "msg": "No data"}), 400 return jsonify({"code": 200, "msg": "success", "data": data})
# 修改:调用 Service 处理入库,获取新创建的对象
new_stock = SemiInboundService.handle_inbound(data)
# 修改返回成功信息以及新创建的数据包含生成的ID和SKU供前端打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 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']) @inbound_semi_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_semi:operation')
def update_semi(id): def update_semi(id):
try: try:
data = request.get_json() 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) SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"}) return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除半成品入库记录
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>', methods=['DELETE']) @inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_semi:operation')
def delete_semi(id): def delete_semi(id):
try: try:
SemiInboundService.delete_inbound(id) SemiInboundService.delete_inbound(id)
@ -123,41 +129,39 @@ def delete_semi(id):
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. [新增] 获取关联出库历史
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>/history', methods=['GET']) @inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_semi')
def get_history(id): def get_history(id):
try: try:
data = SemiInboundService.get_outbound_history(id) data = SemiInboundService.get_outbound_history(id)
return jsonify({ return jsonify({"code": 200, "msg": "success", "data": data})
"code": 200,
"msg": "success",
"data": data
})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 6. 系统用户建议
# ------------------------------------------------------------------
@inbound_semi_bp.route('/suggestions/users', methods=['GET']) @inbound_semi_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_semi')
def get_user_suggestions(): def get_user_suggestions():
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = SemiInboundService.search_system_users(keyword) data = SemiInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
# ------------------------------------------------------------------
# 7. 获取筛选选项
# ------------------------------------------------------------------
@inbound_semi_bp.route('/options', methods=['GET']) @inbound_semi_bp.route('/options', methods=['GET'])
@permission_required('inbound_semi')
def get_options(): def get_options():
try: try:
data = SemiInboundService.get_filter_options() data = SemiInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500 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 flask_jwt_extended import jwt_required
from . import inbound_bp from . import inbound_bp
from app.services.inbound.service_service import ServiceService 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 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']) @inbound_bp.route('/service/search-base', methods=['GET'])
@jwt_required() @permission_required('inbound_service')
def search_base(): def search_base():
"""搜索基础物料""" """搜索基础物料"""
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
try: try:
data = ServiceService.search_base_material(keyword) 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({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
'data': data 'data': filtered_data
}) })
except Exception as e: except Exception as e:
current_app.logger.error(f'搜索基础物料失败: {str(e)}') current_app.logger.error(f'搜索基础物料失败: {str(e)}')
@ -25,7 +77,7 @@ def search_base():
@inbound_bp.route('/service', methods=['GET']) @inbound_bp.route('/service', methods=['GET'])
@jwt_required() @permission_required('inbound_service')
def get_service_list(): def get_service_list():
"""获取服务权益列表""" """获取服务权益列表"""
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@ -44,6 +96,9 @@ def get_service_list():
end_date=end_date, end_date=end_date,
provider_name=provider_name 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({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
@ -56,14 +111,38 @@ def get_service_list():
@inbound_bp.route('/service', methods=['POST']) @inbound_bp.route('/service', methods=['POST'])
@jwt_required() @permission_required('inbound_service:operation')
@role_required('admin,manager')
def create_service(): def create_service():
"""创建服务权益""" """创建服务权益"""
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400 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'): if not data.get('base_id'):
return jsonify({'code': 400, 'msg': '请选择基础物料'}), 400 return jsonify({'code': 400, 'msg': '请选择基础物料'}), 400
@ -72,10 +151,12 @@ def create_service():
try: try:
service = ServiceService.create_service(data) service = ServiceService.create_service(data)
user_permissions = get_current_user_permissions()
filtered_data = filter_item_by_permissions(service.to_dict(), user_permissions)
return jsonify({ return jsonify({
'code': 201, 'code': 201,
'msg': '创建成功', 'msg': '创建成功',
'data': service.to_dict() 'data': filtered_data
}), 201 }), 201
except ValueError as e: except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400 return jsonify({'code': 400, 'msg': str(e)}), 400
@ -86,15 +167,17 @@ def create_service():
@inbound_bp.route('/service/<int:service_id>', methods=['GET']) @inbound_bp.route('/service/<int:service_id>', methods=['GET'])
@jwt_required() @permission_required('inbound_service')
def get_service(service_id): def get_service(service_id):
"""获取单个服务权益详情""" """获取单个服务权益详情"""
try: try:
service = ServiceService.get_service(service_id) 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({ return jsonify({
'code': 200, 'code': 200,
'msg': 'success', 'msg': 'success',
'data': service.to_dict() 'data': filtered_data
}) })
except ValueError as e: except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404 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']) @inbound_bp.route('/service/<int:service_id>', methods=['PUT'])
@jwt_required() @permission_required('inbound_service:operation')
@role_required('admin,manager')
def update_service(service_id): def update_service(service_id):
"""更新服务权益""" """更新服务权益"""
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400 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 = { allowed_fields = {
'sale_price', 'provider_name', 'description', 'sale_price', 'provider_name', 'description',
@ -124,10 +231,12 @@ def update_service(service_id):
try: try:
service = ServiceService.update_service(service_id, filtered_data) 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({ return jsonify({
'code': 200, 'code': 200,
'msg': '更新成功', 'msg': '更新成功',
'data': service.to_dict() 'data': filtered_service
}) })
except ValueError as e: except ValueError as e:
return jsonify({'code': 404, 'msg': str(e)}), 404 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']) @inbound_bp.route('/service/<int:service_id>', methods=['DELETE'])
@jwt_required() @permission_required('inbound_service:operation')
@role_required('admin,manager')
def delete_service(service_id): def delete_service(service_id):
"""删除服务权益""" """删除服务权益"""
try: try:
@ -155,7 +263,7 @@ def delete_service(service_id):
@inbound_bp.route('/service/suggestions/providers', methods=['GET']) @inbound_bp.route('/service/suggestions/providers', methods=['GET'])
@jwt_required() @permission_required('inbound_service')
def get_provider_suggestions(): def get_provider_suggestions():
base_id = request.args.get('base_id', type=int) base_id = request.args.get('base_id', type=int)
if not base_id: if not base_id:
@ -165,7 +273,7 @@ def get_provider_suggestions():
@inbound_bp.route('/service/suggestions/users', methods=['GET']) @inbound_bp.route('/service/suggestions/users', methods=['GET'])
@jwt_required() @permission_required('inbound_service')
def get_user_suggestions(): def get_user_suggestions():
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = ServiceService.search_system_users(keyword) data = ServiceService.search_system_users(keyword)
@ -173,7 +281,7 @@ def get_user_suggestions():
@inbound_bp.route('/service/options', methods=['GET']) @inbound_bp.route('/service/options', methods=['GET'])
@jwt_required() @permission_required('inbound_service')
def get_options(): def get_options():
try: try:
data = ServiceService.get_filter_options() data = ServiceService.get_filter_options()

View File

@ -2,6 +2,7 @@ from flask import Blueprint, jsonify, request
from app.extensions import db from app.extensions import db
# ★★★ 修复点:必须引入 datetime否则下方更新时间时会报错 500 ★★★ # ★★★ 修复点:必须引入 datetime否则下方更新时间时会报错 500 ★★★
from datetime import datetime from datetime import datetime
from app.utils.decorators import permission_required
# 导入模型 # 导入模型
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
@ -24,6 +25,7 @@ bp = Blueprint('stock_ops', __name__)
@bp.route('/all', methods=['GET']) @bp.route('/all', methods=['GET'])
@permission_required('inventory_stocktake')
def get_all_stock(): def get_all_stock():
""" """
获取所有库存 > 0 的物品 获取所有库存 > 0 的物品
@ -63,6 +65,7 @@ def get_all_stock():
# --- 草稿箱接口 --- # --- 草稿箱接口 ---
@bp.route('/draft/list', methods=['GET']) @bp.route('/draft/list', methods=['GET'])
@permission_required('inventory_stocktake')
def get_drafts(): def get_drafts():
"""获取当前用户的盘点进度""" """获取当前用户的盘点进度"""
user_id = request.args.get('user_id', 'admin') user_id = request.args.get('user_id', 'admin')
@ -71,6 +74,7 @@ def get_drafts():
@bp.route('/draft/add', methods=['POST']) @bp.route('/draft/add', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def add_draft(): def add_draft():
"""扫码同步 (支持更新数量)""" """扫码同步 (支持更新数量)"""
try: try:
@ -100,6 +104,7 @@ def add_draft():
@bp.route('/draft/clear', methods=['POST']) @bp.route('/draft/clear', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def clear_draft(): def clear_draft():
"""清空进度""" """清空进度"""
data = request.json data = request.json
@ -110,9 +115,26 @@ def clear_draft():
return jsonify({"message": "Cleared"}), 200 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']) @bp.route('/print/selection', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def print_selection(): def print_selection():
try: try:
data = request.json data = request.json
@ -126,6 +148,7 @@ def print_selection():
@bp.route('/print/stocktake', methods=['POST']) @bp.route('/print/stocktake', methods=['POST'])
@permission_required('inventory_stocktake:operation')
def print_stocktake(): def print_stocktake():
try: try:
data = request.json data = request.json

View File

@ -1,17 +1,80 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from app.services.outbound_service import OutboundService 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 import traceback
outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound') 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. 扫码查询库存接口 (关联三个库存表) # 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/outbound/scan?barcode=... # GET /api/v1/outbound/scan?barcode=...
# -------------------------------------------------------- # --------------------------------------------------------
@outbound_bp.route('/scan', methods=['GET']) @outbound_bp.route('/scan', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('outbound_selection')
def scan_barcode(): def scan_barcode():
barcode = request.args.get('barcode') barcode = request.args.get('barcode')
if not barcode: if not barcode:
@ -45,6 +108,19 @@ def scan_barcode():
@outbound_bp.route('', methods=['POST']) @outbound_bp.route('', methods=['POST'])
@jwt_required() @jwt_required()
def create_outbound(): 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() data = request.get_json()
if not data: if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400 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'): if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400 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: try:
# ★ [修改] 调用批量创建服务 # ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator) outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
@ -89,6 +203,7 @@ def create_outbound():
# -------------------------------------------------------- # --------------------------------------------------------
@outbound_bp.route('', methods=['GET']) @outbound_bp.route('', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('outbound_list')
def get_outbound_list(): def get_outbound_list():
try: try:
page = int(request.args.get('page', 1)) page = int(request.args.get('page', 1))
@ -99,6 +214,11 @@ def get_outbound_list():
# ★ [修改] 调用分组查询服务 # ★ [修改] 调用分组查询服务
result = OutboundService.get_grouped_list(page, limit, keyword) 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({ return jsonify({
'code': 200, 'code': 200,
'msg': '获取成功', 'msg': '获取成功',

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 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 from app.services.trans_service import TransService
import traceback import traceback
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions') 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']) @trans_bp.route('/borrow', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('op_borrow:operation')
def create_borrow(): def create_borrow():
data = request.get_json() 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: try:
no = TransService.create_borrow(data) no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}}) return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
@ -21,6 +91,7 @@ def create_borrow():
# --- 还库辅助:扫码查找借出记录 --- # --- 还库辅助:扫码查找借出记录 ---
@trans_bp.route('/return/scan', methods=['GET']) @trans_bp.route('/return/scan', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('op_return')
def scan_borrowed_item(): def scan_borrowed_item():
barcode = request.args.get('barcode') barcode = request.args.get('barcode')
if not barcode: if not barcode:
@ -36,8 +107,29 @@ def scan_borrowed_item():
# --- 还库提交 --- # --- 还库提交 ---
@trans_bp.route('/return', methods=['POST']) @trans_bp.route('/return', methods=['POST'])
@jwt_required() @jwt_required()
@permission_required('op_return:operation')
def submit_return(): def submit_return():
data = request.get_json() 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() # 库管 user = get_jwt_identity() # 库管
try: try:
TransService.process_return(data, operator_name=user) TransService.process_return(data, operator_name=user)
@ -49,10 +141,15 @@ def submit_return():
# --- 记录列表 --- # --- 记录列表 ---
@trans_bp.route('/records', methods=['GET']) @trans_bp.route('/records', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('op_records')
def get_records(): def get_records():
status = request.args.get('status', 'all') status = request.args.get('status', 'all')
page = int(request.args.get('page', 1)) page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
res = TransService.get_records(page=page, limit=10, status=status, keyword=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}) 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) 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) # 总价 total_price = db.Column(db.Numeric(19, 4), default=0) # 总价
# [新增] 税率 # [新增] 税率
tax_rate = db.Column(db.Numeric(5, 2), 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), 'available_quantity': float(self.available_quantity or 0),
'qty_available': 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), 'total_price': float(self.total_price or 0),
# [新增] 税率 # [新增] 税率
'tax_rate': float(self.tax_rate or 0), 'tax_rate': float(self.tax_rate or 0),

View File

@ -1,14 +1,17 @@
# app/models/system.py # inventory-backend/app/models/system.py
from app.extensions import db from app.extensions import db
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime
# ==========================================
# 1. 系统用户表
# ==========================================
class SysUser(db.Model): class SysUser(db.Model):
""" """
系统用户表 系统用户表
对应数据库: sys_user 对应数据库: sys_user
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan) username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan01)
""" """
__tablename__ = 'sys_user' __tablename__ = 'sys_user'
@ -19,8 +22,7 @@ class SysUser(db.Model):
role = db.Column(db.String(50)) role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active') status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text) password_hash = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now)
# created_at 已在数据库脚本中移除,此处不再定义
def set_password(self, password): def set_password(self, password):
"""生成加密密码""" """生成加密密码"""
@ -45,23 +47,27 @@ class SysUser(db.Model):
parts = raw_name.split('/') parts = raw_name.split('/')
real_name = parts[0] real_name = parts[0]
acc_id = parts[1] acc_id = parts[1]
# 格式化为前端展示格式: 张三(zhangsan) # 格式化为前端展示格式: 张三(zhangsan01)
display_name = f"{real_name}({acc_id})" display_name = f"{real_name}({acc_id})"
# 单独提取账号ID (如果前端需要单独用) # 单独提取账号ID (如果前端需要单独用)
account_id = acc_id account_id = acc_id
return { return {
'id': self.id, 'id': self.id,
'username': display_name, # 列表显示: 张三(zhangsan) 'username': display_name, # 列表显示: 张三(zhangsan01)
'raw_username': self.username, # 原始数据 'raw_username': self.username, # 原始数据
'account_id': account_id, # 纯账号ID: zhangsan 'account_id': account_id, # 纯账号ID: zhangsan01
'email': self.email, 'email': self.email,
'department': self.department, 'department': self.department,
'role': self.role, '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): class SysLog(db.Model):
""" """
系统操作日志表 系统操作日志表
@ -89,3 +95,57 @@ class SysLog(db.Model):
'action_type': self.action_type, 'action_type': self.action_type,
'description': self.description '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 app.extensions import db
from datetime import datetime from datetime import datetime
from sqlalchemy import func
class TransBorrow(db.Model): class TransBorrow(db.Model):
@ -46,6 +47,19 @@ class TransBorrow(db.Model):
'remark': self.remark, '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): class TransRepair(db.Model):
__tablename__ = 'trans_repair' __tablename__ = 'trans_repair'

View File

@ -1,11 +1,11 @@
# app/services/auth_service.py # 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 app.extensions import db
from sqlalchemy import func
from flask_jwt_extended import create_access_token from flask_jwt_extended import create_access_token
from app.utils.constants import UserRole from app.utils.constants import UserRole
from datetime import timedelta from datetime import timedelta
class AuthService: class AuthService:
# 硬编码的超级管理员凭证 # 硬编码的超级管理员凭证
SUPER_ADMIN_USER = "IRIS" SUPER_ADMIN_USER = "IRIS"
@ -52,9 +52,10 @@ class AuthService:
if user.status != 'active': if user.status != 'active':
raise ValueError("账号已被禁用,请联系管理员") raise ValueError("账号已被禁用,请联系管理员")
user_role = user.role user_role = user.role.upper() if user.role else None
user_id = user.id user_id = user.id
user_info = user.to_dict() user_info = user.to_dict()
user_info['role'] = user_role
# 3. 生成 Token # 3. 生成 Token
# Token 中 identity 存数据库IDclaims 存登录账号ID # Token 中 identity 存数据库IDclaims 存登录账号ID
@ -81,7 +82,9 @@ class AuthService:
创建新用户 创建新用户
data 包含: cn_name(张三), username(zhangsan), ... 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("权限不足:只有超级管理员或主管可以创建新用户") raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
cn_name = data.get('cn_name') cn_name = data.get('cn_name')
@ -90,7 +93,8 @@ class AuthService:
if not cn_name or not pinyin_base: if not cn_name or not pinyin_base:
raise Exception("姓名和账号不能为空") raise Exception("姓名和账号不能为空")
role = data.get('role') role_raw = data.get('role')
role = role_raw.upper() if role_raw else None
# 验证角色合法性 # 验证角色合法性
valid_roles = [ valid_roles = [
@ -101,7 +105,7 @@ class AuthService:
if role not in valid_roles: if role not in valid_roles:
raise Exception(f"角色无效") 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("权限不足:主管无法创建超级管理员") raise Exception("权限不足:主管无法创建超级管理员")
email = data.get('email', '') email = data.get('email', '')
@ -150,7 +154,9 @@ class AuthService:
更新用户信息 更新用户信息
注意: 这里暂时不允许修改用户名/账号,因为涉及 split 逻辑较复杂,且通常账号不开通后不改 注意: 这里暂时不允许修改用户名/账号,因为涉及 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("权限不足") raise Exception("权限不足")
user = SysUser.query.get(user_id) user = SysUser.query.get(user_id)
@ -163,10 +169,11 @@ class AuthService:
v for k, v in UserRole.__dict__.items() v for k, v in UserRole.__dict__.items()
if not k.startswith('__') and isinstance(v, str) 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: if new_role not in valid_roles:
raise Exception(f"角色无效") 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("权限不足") raise Exception("权限不足")
user.role = new_role user.role = new_role
@ -202,7 +209,9 @@ class AuthService:
@staticmethod @staticmethod
def delete_user(user_id, operator_role): 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("权限不足:只有超级管理员可以删除用户") raise Exception("权限不足:只有超级管理员可以删除用户")
user = SysUser.query.get(user_id) user = SysUser.query.get(user_id)
@ -212,3 +221,45 @@ class AuthService:
db.session.delete(user) db.session.delete(user)
db.session.commit() 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.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
# from app.models.inbound.service import StockService # from app.models.inbound.service import StockService
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_, func
import traceback import traceback
import json import json
import io import io
@ -14,6 +14,7 @@ import datetime
# 需要 pip install openpyxl # 需要 pip install openpyxl
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from collections import defaultdict
class MaterialBaseService: class MaterialBaseService:
@ -114,7 +115,41 @@ class MaterialBaseService:
获取基础信息列表 (带分页和筛选) 获取基础信息列表 (带分页和筛选)
""" """
try: 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: if filters:
# 1. 关键词模糊搜索 # 1. 关键词模糊搜索
@ -127,37 +162,47 @@ class MaterialBaseService:
)) ))
# 2. 精确筛选 # 2. 精确筛选
# 公司筛选 company = filters.get('company')
if filters.get('company'): if company is not None and company != '':
query = query.filter_by(company_name=filters['company']) query = query.filter(MaterialBase.company_name.ilike(company.strip()))
if filters.get('category'): category = filters.get('category')
query = query.filter_by(category=filters['category']) if category is not None and category != '':
query = query.filter(MaterialBase.category.ilike(category.strip()))
if filters.get('type'): type_val = filters.get('type')
query = query.filter_by(material_type=filters['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: if filters.get('isEnabled') is not None:
is_active = bool(int(filters['isEnabled'])) is_active = bool(int(filters['isEnabled']))
query = query.filter_by(is_enabled=is_active) 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, order_by_column = filters.get('orderByColumn', '')
error_out=False) 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 = [] items_list = []
for item in pagination.items: for item, inv, avail in pagination.items:
item_dict = item.to_dict() item_dict = item.to_dict()
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
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
items_list.append(item_dict) items_list.append(item_dict)
return {"total": pagination.total, "items": items_list} return {"total": pagination.total, "items": items_list}
@ -217,7 +262,6 @@ class MaterialBaseService:
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})") raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
new_material = MaterialBase( new_material = MaterialBase(
# [修改] 移除了 'IRIS' 默认值
company_name=data.get('companyName'), company_name=data.get('companyName'),
name=data['name'], name=data['name'],
common_name=data.get('commonName'), common_name=data.get('commonName'),
@ -307,13 +351,13 @@ class MaterialBaseService:
raise e raise e
# ============================================================================== # ==============================================================================
# [核心修改] 统一资产统计导出 # [核心修改] 统一资产统计导出(增加最高单价计算逻辑)
# ============================================================================== # ==============================================================================
@staticmethod @staticmethod
def export_excel(filters=None): def export_excel(filters=None, user_permissions=None):
""" """
全口径资产统计报表: 全口径资产统计报表:
逻辑:先查库存表 (Buy, Semi, Product),关联基础信息,只导出实际存在的库存记录。 根据基础信息列表和库存表,计算出每个物料的最高历史单价并进行导出
""" """
try: try:
# 1. 构造基础信息的筛选条件 (用于过滤库存) # 1. 构造基础信息的筛选条件 (用于过滤库存)
@ -327,12 +371,15 @@ class MaterialBaseService:
MaterialBase.spec_model.ilike(kw), MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) MaterialBase.company_name.ilike(kw)
)) ))
if filters.get('company'): company = filters.get('company')
filter_conditions.append(MaterialBase.company_name == filters['company']) if company is not None and company != '':
if filters.get('category'): filter_conditions.append(MaterialBase.company_name.ilike(company.strip()))
filter_conditions.append(MaterialBase.category == filters['category']) category = filters.get('category')
if filters.get('type'): if category is not None and category != '':
filter_conditions.append(MaterialBase.material_type == filters['type']) 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: if filters.get('isEnabled') is not None:
is_active = bool(int(filters['isEnabled'])) is_active = bool(int(filters['isEnabled']))
filter_conditions.append(MaterialBase.is_enabled == is_active) filter_conditions.append(MaterialBase.is_enabled == is_active)
@ -362,21 +409,56 @@ class MaterialBaseService:
query_product = query_product.filter(cond) query_product = query_product.filter(cond)
list_product = query_product.all() 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. 数据整合 # 3. 数据整合
all_rows = [] all_rows = []
# 处理采购件 # 处理采购件
for stock, base in list_buy: 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) 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 highest_incl_price = highest_excl_price * (1 + tax_rate / 100.0)
# 计算含税总价 = 数量 * 含税单价 total_val_excl = qty * highest_excl_price
total_val_incl = qty * price_incl total_val_incl = qty * highest_incl_price
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
@ -389,22 +471,21 @@ class MaterialBaseService:
"date": stock.in_date, "date": stock.in_date,
"qty": qty, "qty": qty,
"avail": float(stock.available_quantity or 0), "avail": float(stock.available_quantity or 0),
"price_excl": unit_price, "price_excl": highest_excl_price,
"total_val_excl": total_val_excl, # [新增] "total_val_excl": total_val_excl,
"tax": tax_rate, "tax": tax_rate,
"price_incl": price_incl, "price_incl": highest_incl_price,
"total_val": total_val_incl "total_val": total_val_incl
}) })
# 处理半成品 # 处理半成品
for stock, base in list_semi: 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) qty = float(stock.stock_quantity or 0)
# 半成品的单价直接取自 manual_cost 字段(单件总成本)
unit_cost = float(stock.manual_cost or 0)
# 半成品不含税总价 = 数量 * 成本 total_val_excl = qty * unit_cost
total_val_excl = qty * cost total_val_incl = qty * unit_cost # 半成品无税
# 含税总价同上 (税率0)
total_val_incl = qty * cost
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
@ -417,20 +498,21 @@ class MaterialBaseService:
"date": stock.production_date, "date": stock.production_date,
"qty": qty, "qty": qty,
"avail": float(stock.available_quantity or 0), "avail": float(stock.available_quantity or 0),
"price_excl": cost, "price_excl": unit_cost,
"total_val_excl": total_val_excl, # [新增] "total_val_excl": total_val_excl,
"tax": 0.0, "tax": 0.0,
"price_incl": cost, "price_incl": unit_cost,
"total_val": total_val_incl "total_val": total_val_incl
}) })
# 处理成品 # 处理成品
for stock, base in list_product: 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) qty = float(stock.stock_quantity or 0)
# 成品的单价直接取自 manual_cost 字段(单件总成本)
unit_cost = float(stock.manual_cost or 0)
total_val_excl = qty * cost total_val_excl = qty * unit_cost
total_val_incl = qty * cost total_val_incl = qty * unit_cost
ident = stock.serial_number or stock.barcode or stock.sku ident = stock.serial_number or stock.barcode or stock.sku
@ -443,10 +525,10 @@ class MaterialBaseService:
"date": stock.production_date, "date": stock.production_date,
"qty": qty, "qty": qty,
"avail": float(stock.available_quantity or 0), "avail": float(stock.available_quantity or 0),
"price_excl": cost, "price_excl": unit_cost,
"total_val_excl": total_val_excl, # [新增] "total_val_excl": total_val_excl,
"tax": 0.0, "tax": 0.0,
"price_incl": cost, "price_incl": unit_cost,
"total_val": total_val_incl "total_val": total_val_incl
}) })
@ -463,7 +545,7 @@ class MaterialBaseService:
ws = wb.active ws = wb.active
ws.title = "库存统计" ws.title = "库存统计"
# 表头 [修改] 增加 "资产总额 (不含税)" # 表头 (严格对应你的图 5)
headers = [ headers = [
"所属公司", "资产名称", "规格型号", "物料类型", "所属公司", "资产名称", "规格型号", "物料类型",
"类别一级", "类别二级", "类别三级", "类别四级", "类别五级", "类别一级", "类别二级", "类别三级", "类别四级", "类别五级",
@ -475,6 +557,36 @@ class MaterialBaseService:
] ]
ws.append(headers) 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") 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'), 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.fill = header_fill
cell.border = border_style 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: for r in all_rows:
base = r['base'] base = r['base']
# 类别拆分 # 类别拆分
@ -512,11 +636,56 @@ class MaterialBaseService:
r['qty'], r['qty'],
r['avail'], r['avail'],
r['price_excl'], r['price_excl'],
r['total_val_excl'], # [新增] 对应列 r['total_val_excl'],
r['tax'], r['tax'],
r['price_incl'], r['price_incl'],
r['total_val'] 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) ws.append(row_val)
# 列宽调整 # 列宽调整

View File

@ -117,7 +117,13 @@ class BuyInboundService:
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') 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: try:
seq_sql = text("SELECT nextval('global_print_seq')") seq_sql = text("SELECT nextval('global_print_seq')")
@ -137,8 +143,9 @@ class BuyInboundService:
warehouse_location=data.get('warehouse_location'), warehouse_location=data.get('warehouse_location'),
# 价格信息 # 价格信息
unit_price=u_price, pre_tax_unit_price=u_price,
tax_rate=tax_rate, # [新增] post_tax_unit_price=post_tax_price,
tax_rate=tax_rate,
total_price=in_qty * u_price, total_price=in_qty * u_price,
currency=data.get('currency', 'CNY'), currency=data.get('currency', 'CNY'),
exchange_rate=data.get('exchange_rate', 1.0), 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 '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 '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: if 'in_quantity' in data:
diff = float(data['in_quantity']) - float(stock.in_quantity) diff = float(data['in_quantity']) - float(stock.in_quantity)
@ -192,9 +213,8 @@ class BuyInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_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.pre_tax_unit_price)
stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
db.session.commit() db.session.commit()
return stock return stock
except Exception as e: except Exception as e:

View File

@ -10,9 +10,6 @@ import json
class ProductInboundService: class ProductInboundService:
# ============================================================
# 0. 辅助:唯一性校验
# ============================================================
@staticmethod @staticmethod
def _check_unique(serial_number, exclude_id=None): def _check_unique(serial_number, exclude_id=None):
from app.models.inbound.product import StockProduct 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 "未知物料" occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。") raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod @staticmethod
def search_base_material(keyword): def search_base_material(keyword, page=1, limit=50):
try: try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword: if keyword:
@ -38,15 +32,16 @@ class ProductInboundService:
or_( or_(
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.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 = [] results = []
for item in query.all(): for item in pagination.items:
results.append({ results.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增] 'company_name': item.company_name,
'name': item.name, 'name': item.name,
'spec': item.spec_model, 'spec': item.spec_model,
'category': item.category, 'category': item.category,
@ -54,14 +49,16 @@ class ProductInboundService:
'type': item.material_type, 'type': item.material_type,
'status': '启用' 'status': '启用'
}) })
return results return {
"items": results,
"total": pagination.total,
"page": page,
"has_next": pagination.has_next
}
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
return [] return {"items": [], "total": 0, "page": 1, "has_next": False}
# ============================================================
# 1.5 BOM 搜索逻辑
# ============================================================
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword):
from app.models.bom import BomTable from app.models.bom import BomTable
@ -98,9 +95,6 @@ class ProductInboundService:
traceback.print_exc() traceback.print_exc()
return [] return []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod @staticmethod
def handle_inbound(data): def handle_inbound(data):
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
@ -132,6 +126,11 @@ class ProductInboundService:
in_date_val = current_time in_date_val = current_time
in_qty = float(data.get('in_quantity') or 0) 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_start = data.get('production_start_time', '')
p_end = data.get('production_end_time', '') p_end = data.get('production_end_time', '')
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None 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'), work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'), production_manager=data.get('production_manager'),
production_time_range=time_range, production_time_range=time_range,
raw_material_cost=float(data.get('raw_material_cost') or 0), raw_material_cost=raw_cost,
manual_cost=float(data.get('manual_cost') or 0), manual_cost=unit_total_cost,
quality_status=data.get('quality_status', '合格'), quality_status=data.get('quality_status', '合格'),
product_photo=json.dumps(photo_list), product_photo=json.dumps(photo_list),
quality_report_link=json.dumps(quality_list), quality_report_link=json.dumps(quality_list),
@ -188,9 +187,6 @@ class ProductInboundService:
db.session.rollback() db.session.rollback()
raise e raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod @staticmethod
def update_inbound(stock_id, data): def update_inbound(stock_id, data):
from app.models.inbound.product import StockProduct 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 '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 '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: if 'in_quantity' in data:
new_qty = float(data['in_quantity']) new_qty = float(data['in_quantity'])
@ -234,6 +230,7 @@ class ProductInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff
if 'production_start_time' in data or 'production_end_time' in data: if 'production_start_time' in data or 'production_end_time' in data:
old_range = stock.production_time_range or " ~ " old_range = stock.production_time_range or " ~ "
parts = old_range.split(' ~ ') parts = old_range.split(' ~ ')
@ -249,9 +246,6 @@ class ProductInboundService:
db.session.rollback() db.session.rollback()
raise e raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod @staticmethod
def delete_inbound(stock_id): def delete_inbound(stock_id):
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
@ -265,9 +259,6 @@ class ProductInboundService:
db.session.rollback() db.session.rollback()
raise e raise e
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod @staticmethod
def get_outbound_history(stock_id): def get_outbound_history(stock_id):
try: try:
@ -278,9 +269,6 @@ class ProductInboundService:
except: except:
return [] return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
@ -291,7 +279,7 @@ class ProductInboundService:
query = query.filter(or_( query = query.filter(or_(
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw), MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw), # [新增] MaterialBase.company_name.ilike(kw),
StockProduct.serial_number.ilike(kw), StockProduct.serial_number.ilike(kw),
StockProduct.work_order_code.ilike(kw), StockProduct.work_order_code.ilike(kw),
StockProduct.order_id.ilike(kw), StockProduct.order_id.ilike(kw),
@ -302,7 +290,6 @@ class ProductInboundService:
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增]
if company and company.strip(): if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip()) query = query.filter(MaterialBase.company_name == company.strip())
@ -330,7 +317,9 @@ class ProductInboundService:
items = [] items = []
for item in current_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} return {"total": pagination.total, "items": items}
except: except:
traceback.print_exc() traceback.print_exc()
@ -358,29 +347,20 @@ class ProductInboundService:
except Exception: except Exception:
return [] return []
# ============================================================
# 7. 获取筛选项
# ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 类别 categories = db.session.query(MaterialBase.category).filter(MaterialBase.category != None,
categories = db.session.query(MaterialBase.category) \ MaterialBase.category != '').distinct().all()
.filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all()
sorted_categories = sorted([r[0] for r in categories]) sorted_categories = sorted([r[0] for r in categories])
# 类型 types = db.session.query(MaterialBase.material_type).filter(MaterialBase.material_type != None,
types = db.session.query(MaterialBase.material_type) \ MaterialBase.material_type != '').distinct().all()
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all()
sorted_types = sorted([r[0] for r in types]) sorted_types = sorted([r[0] for r in types])
# [新增] 公司 companies = db.session.query(MaterialBase.company_name).filter(MaterialBase.company_name != None,
companies = db.session.query(MaterialBase.company_name) \ MaterialBase.company_name != '').distinct().all()
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
.distinct().all()
sorted_companies = sorted([r[0] for r in companies]) sorted_companies = sorted([r[0] for r in companies])
return { return {
@ -392,3 +372,70 @@ class ProductInboundService:
import traceback import traceback
traceback.print_exc() 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: class SemiInboundService:
# ============================================================
# 0. 辅助:唯一性校验
# ============================================================
@staticmethod @staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None): def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
@ -35,11 +32,8 @@ class SemiInboundService:
if query.first(): if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。") raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod @staticmethod
def search_base_material(keyword): def search_base_material(keyword, page=1, limit=50):
try: try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword: if keyword:
@ -48,15 +42,16 @@ class SemiInboundService:
or_( or_(
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.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 = [] results = []
for item in query.all(): for item in pagination.items:
results.append({ results.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增] 'company_name': item.company_name,
'name': item.name, 'name': item.name,
'spec': item.spec_model, 'spec': item.spec_model,
'category': item.category, 'category': item.category,
@ -64,14 +59,11 @@ class SemiInboundService:
'type': item.material_type, 'type': item.material_type,
'status': '启用' 'status': '启用'
}) })
return results return {"items": results, "total": pagination.total, "page": page, "has_next": pagination.has_next}
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return [] return {"items": [], "total": 0, "page": 1, "has_next": False}
# ============================================================
# 1.5 BOM 搜索逻辑
# ============================================================
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword):
from app.models.bom import BomTable from app.models.bom import BomTable
@ -108,9 +100,6 @@ class SemiInboundService:
traceback.print_exc() traceback.print_exc()
return [] return []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod @staticmethod
def handle_inbound(data): def handle_inbound(data):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
@ -167,9 +156,9 @@ class SemiInboundService:
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
raw_cost = float(data.get('raw_material_cost') or 0) raw_cost = float(data.get('raw_material_cost') or 0)
manual_cost = float(data.get('manual_cost') or 0) # 【重要修改】:把前端的 unit_total_cost单件成本存入原数据库的 manual_cost 字段中
unit_total_cost = raw_cost + manual_cost unit_cost = float(data.get('unit_total_cost') or raw_cost)
total_value = unit_total_cost * in_qty total_value = unit_cost * in_qty
next_global_id = 0 next_global_id = 0
try: try:
@ -215,7 +204,7 @@ class SemiInboundService:
production_end_time=p_end, production_end_time=p_end,
production_time_range=time_range_str, production_time_range=time_range_str,
raw_material_cost=raw_cost, raw_material_cost=raw_cost,
manual_cost=manual_cost, manual_cost=unit_cost, # 映射到 manual_cost 物理字段
total_price=total_value, total_price=total_value,
arrival_photo=json.dumps(arrival_list), arrival_photo=json.dumps(arrival_list),
quality_report_link=json.dumps(quality_report_list), quality_report_link=json.dumps(quality_report_list),
@ -231,9 +220,6 @@ class SemiInboundService:
traceback.print_exc() traceback.print_exc()
raise e raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod @staticmethod
def update_inbound(stock_id, data): def update_inbound(stock_id, data):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
@ -307,7 +293,6 @@ class SemiInboundService:
stock.production_time_range = raw_range stock.production_time_range = raw_range
qty_changed = False qty_changed = False
cost_changed = False
if 'in_quantity' in data: if 'in_quantity' in data:
new_qty = float(data['in_quantity']) new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity) diff = new_qty - float(stock.in_quantity)
@ -316,15 +301,16 @@ class SemiInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff
qty_changed = True qty_changed = True
if 'raw_material_cost' in data: if 'raw_material_cost' in data:
stock.raw_material_cost = float(data['raw_material_cost']) stock.raw_material_cost = float(data['raw_material_cost'])
cost_changed = True if 'unit_total_cost' in data:
if 'manual_cost' in data: stock.manual_cost = float(data['unit_total_cost']) # 映射到 manual_cost 物理字段
stock.manual_cost = float(data['manual_cost'])
cost_changed = True if 'unit_total_cost' in data or qty_changed:
if cost_changed or qty_changed: qty = float(stock.in_quantity or 1)
unit_total = float(stock.raw_material_cost) + float(stock.manual_cost) # 使用存入 manual_cost 的单价计算总价
stock.total_price = float(stock.in_quantity) * unit_total stock.total_price = float(stock.manual_cost or 0) * qty
db.session.commit() db.session.commit()
return stock return stock
@ -332,9 +318,6 @@ class SemiInboundService:
db.session.rollback() db.session.rollback()
raise e raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod @staticmethod
def delete_inbound(stock_id): def delete_inbound(stock_id):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
@ -349,9 +332,6 @@ class SemiInboundService:
db.session.rollback() db.session.rollback()
raise e raise e
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod @staticmethod
def get_outbound_history(stock_id): def get_outbound_history(stock_id):
try: try:
@ -362,9 +342,6 @@ class SemiInboundService:
except: except:
return [] return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
@ -376,7 +353,7 @@ class SemiInboundService:
or_( or_(
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw), MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw), # [新增] MaterialBase.company_name.ilike(kw),
StockSemi.batch_number.ilike(kw), StockSemi.batch_number.ilike(kw),
StockSemi.serial_number.ilike(kw), StockSemi.serial_number.ilike(kw),
StockSemi.sku.ilike(kw), StockSemi.sku.ilike(kw),
@ -389,7 +366,6 @@ class SemiInboundService:
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增] 公司筛选
if company and company.strip(): if company and company.strip():
query = query.filter(MaterialBase.company_name == 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, pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False) 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 = [] items = []
for item in current_items: for item in pagination.items:
items.append(item.to_dict()) # 直接使用 Model 的 to_dict (已包含 company_name) # 把 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} return {"total": pagination.total, "items": items}
except Exception as e: except Exception as e:
print(f"List Error: {e}") print(f"List Error: {e}")
@ -446,29 +416,18 @@ class SemiInboundService:
except Exception: except Exception:
return [] return []
# ============================================================
# 7. 获取筛选项 (排序)
# ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 类别 categories = db.session.query(MaterialBase.category).filter(MaterialBase.category != None,
categories = db.session.query(MaterialBase.category) \ MaterialBase.category != '').distinct().all()
.filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all()
sorted_categories = sorted([r[0] for r in categories]) 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]) 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]) sorted_companies = sorted([r[0] for r in companies])
return { return {
@ -477,6 +436,69 @@ class SemiInboundService:
"companies": sorted_companies "companies": sorted_companies
} }
except Exception: except Exception:
import traceback
traceback.print_exc() 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': if table_type == 'stock_product':
return float(item.sale_price) if item.sale_price else 0 return float(item.sale_price) if item.sale_price else 0
elif table_type == 'stock_buy': 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 return 0
prod = StockProduct.query.filter( 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 # app/utils/decorators.py
from functools import wraps from functools import wraps
from flask_jwt_extended import get_jwt from flask_jwt_extended import get_jwt, verify_jwt_in_request
from flask import jsonify from flask import jsonify, g
import logging
def role_required(*roles): def role_required(*roles):
@ -15,12 +16,13 @@ def role_required(*roles):
def decorator(*args, **kwargs): def decorator(*args, **kwargs):
claims = get_jwt() claims = get_jwt()
user_role = claims.get('role') 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) 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 jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs) return fn(*args, **kwargs)
@ -28,3 +30,60 @@ def role_required(*roles):
return decorator 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"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus' import { ElMessageBox, ElMessage } from 'element-plus'
import { InfoFilled, SwitchButton, UserFilled } from '@element-plus/icons-vue' import { InfoFilled, SwitchButton, UserFilled } from '@element-plus/icons-vue'
@ -14,6 +14,13 @@ const isLoginPage = computed(() => {
return route.path === '/login' return route.path === '/login'
}) })
// 页面加载时刷新权限
onMounted(() => {
if (userStore.token) {
userStore.refreshUserPermissions()
}
})
// --- 退出登录逻辑 Start --- // --- 退出登录逻辑 Start ---
const handleLogout = () => { const handleLogout = () => {
ElMessageBox.confirm( ElMessageBox.confirm(
@ -82,7 +89,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本: 1.3 Beta (2.25权限管理版) 当前版本: 2.2录入测试版
</span> </span>
</footer> </footer>
</div> </div>

View File

@ -11,8 +11,11 @@ export function getBomList(params?: any) {
// 获取BOM详情 // 获取BOM详情
export function getBomDetail(bomNo: string) { export function getBomDetail(bomNo: string) {
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
const trimmed = bomNo.replace(/^\/+|\/+$/g, '');
const encoded = encodeURIComponent(trimmed);
return request({ return request({
url: `/v1/bom/detail/${bomNo}`, url: `/v1/bom/detail/${encoded}`,
method: 'get' method: 'get'
}) })
} }
@ -28,8 +31,11 @@ export function saveBom(data: any) {
// 删除BOM暂未实现预留 // 删除BOM暂未实现预留
export function deleteBom(bomNo: string) { export function deleteBom(bomNo: string) {
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
const trimmed = bomNo.replace(/^\/+|\/+$/g, '');
const encoded = encodeURIComponent(trimmed);
return request({ return request({
url: `/v1/bom/${bomNo}`, url: `/v1/bom/${encoded}`,
method: 'delete' 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({ return request({
url: '/inbound/product/search-base', url: '/inbound/product/search-base',
method: 'get', method: 'get',
params: { keyword } params: { keyword, page }
}) })
} }
@ -66,3 +67,12 @@ export function getFilterOptions() {
method: 'get' 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. 搜索基础物料 // 5. 搜索基础物料 (已增加 page 参数)
export function searchMaterialBase(keyword: string) { export function searchMaterialBase(keyword: string, page: number = 1) {
return request({ return request({
url: '/inbound/semi/search-base', url: '/inbound/semi/search-base',
method: 'get', method: 'get',
params: { keyword } params: { keyword, page }
}) })
} }
@ -69,3 +69,12 @@ export function getFilterOptions() {
method: 'get' 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: { meta: {
title: '账号开通', title: '账号开通',
icon: 'User', icon: 'User',
// 子路由也建议加上权限限制
roles: ['SUPER_ADMIN', 'SUPERVISOR'] 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') const token = userStore.token || localStorage.getItem('token')
// [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效 // [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效
// 注意Store 中存储的可能是 user.role 或者直接是 role根据你之前的 store 结构适配
const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user' const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user'
const userRole = String(rawRole).toUpperCase() const userRole = String(rawRole).toUpperCase()
// 调试日志:如果跳转有问题,请按 F12 查看控制台输出 // 调试日志
if (to.path.includes('/system')) { if (to.path.includes('/system')) {
console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`) 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)) { if (to.meta.roles && Array.isArray(to.meta.roles)) {
// [修复] to.meta.roles 里已经是大写了userRole 也转大写了,现在可以安全比对
if (to.meta.roles.includes(userRole)) { if (to.meta.roles.includes(userRole)) {
next() next()
} else { } 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 { defineStore } from 'pinia'
import { login } from '@/api/auth' import { login } from '@/api/auth'
import { getRolePermissions } from '@/api/system/permission'
import { ref } from 'vue' import { ref } from 'vue'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
@ -7,6 +8,7 @@ export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '') const token = ref(localStorage.getItem('token') || '')
const role = ref(localStorage.getItem('role') || '') const role = ref(localStorage.getItem('role') || '')
const username = ref(localStorage.getItem('username') || '') const username = ref(localStorage.getItem('username') || '')
const permissions = ref<string[]>(JSON.parse(localStorage.getItem('permissions') || '[]'))
// 2. Actions // 2. Actions
// 登录逻辑 // 登录逻辑
@ -33,7 +35,8 @@ export const useUserStore = defineStore('user', () => {
// 处理用户信息 (确保后端返回结构中有 user 字段) // 处理用户信息 (确保后端返回结构中有 user 字段)
if (data.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 || '用户' username.value = data.user.username || '用户'
// 持久化存储用户信息 // 持久化存储用户信息
@ -44,6 +47,25 @@ export const useUserStore = defineStore('user', () => {
// 持久化存储 Token // 持久化存储 Token
localStorage.setItem('token', data.access_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 表示登录成功 return true // 返回 true 表示登录成功
} }
@ -53,11 +75,36 @@ export const useUserStore = defineStore('user', () => {
token.value = '' token.value = ''
role.value = '' role.value = ''
username.value = '' username.value = ''
permissions.value = []
// 2. 清空 LocalStorage (硬盘) // 2. 清空 LocalStorage (硬盘)
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('role') localStorage.removeItem('role')
localStorage.removeItem('username') 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 // 3. Getters / Helpers
@ -66,12 +113,24 @@ export const useUserStore = defineStore('user', () => {
return roles.includes(role.value) 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 { return {
token, token,
role, role,
username, username,
permissions,
handleLogin, handleLogin,
logout, logout,
hasRole refreshUserPermissions,
hasRole,
hasPermission
} }
}) })

View File

@ -17,29 +17,29 @@
<el-button :icon="Search" @click="fetchBomList" /> <el-button :icon="Search" @click="fetchBomList" />
</template> </template>
</el-input> </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>
</div> </div>
</template> </template>
<el-table v-loading="loading" :data="bomList" border style="width: 100%"> <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 v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable />
<el-table-column prop="parent_name" label="父件名称" min-width="150" /> <el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" />
<el-table-column prop="parent_spec" label="父件规格" min-width="150" /> <el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" />
<el-table-column prop="version" label="版本" width="100" align="center"> <el-table-column v-if="hasColumnPermission('version')" prop="version" label="版本" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag>{{ row.version }}</el-tag> <el-tag>{{ row.version }}</el-tag>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'"> <el-tag :type="row.is_enabled ? 'success' : 'danger'">
{{ row.is_enabled ? '启用' : '禁用' }} {{ row.is_enabled ? '启用' : '禁用' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="child_count" label="子件数" width="80" align="center" /> <el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column label="操作" width="250" align="center" fixed="right"> <el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button> <el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
@ -54,7 +54,7 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="16"> <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 <el-select
v-model="form.parent_id" v-model="form.parent_id"
placeholder="请搜索并选择父件" placeholder="请搜索并选择父件"
@ -79,15 +79,15 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled"> <el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" /> <el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="14"> <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"> <el-input v-model="form.bom_suffix" placeholder="输入后缀 (如 -001)" :disabled="isEditMode">
<template #prepend v-if="form.bom_prefix">{{ form.bom_prefix }}</template> <template #prepend v-if="form.bom_prefix">{{ form.bom_prefix }}</template>
</el-input> </el-input>
@ -97,7 +97,7 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="10"> <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-input v-model="form.version" placeholder="如: V1.0" />
</el-form-item> </el-form-item>
</el-col> </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> <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 :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 }"> <template #default="{ row, $index }">
<el-select <el-select
v-model="row.child_id" v-model="row.child_id"
@ -129,26 +129,26 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="用量" width="140"> <el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number v-model="row.dosage" :min="0" :precision="4" style="width: 100%" controls-position="right" /> <el-input-number v-model="row.dosage" :min="0" :precision="4" style="width: 100%" controls-position="right" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="备注" width="150"> <el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" /> <el-input v-model="row.remark" placeholder="备注" />
</template> </template>
</el-table-column> </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 }"> <template #default="{ $index }">
<el-button type="danger" link @click="removeChild($index)"></el-button> <el-button type="danger" link @click="removeChild($index)"></el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </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> <el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
</div> </div>
</el-form> </el-form>
@ -169,6 +169,7 @@ import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue' import { Plus, Search } from '@element-plus/icons-vue'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom' import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock' import { getMaterialBaseList } from '@/api/inbound/stock'
import { useUserStore } from '@/stores/user'
// 类型定义 // 类型定义
interface BomItem { interface BomItem {
@ -190,6 +191,7 @@ interface ChildRow {
remark: string remark: string
} }
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
@ -199,6 +201,41 @@ const bomList = ref<BomItem[]>([])
const materialOptions = ref<MaterialBase[]>([]) const materialOptions = ref<MaterialBase[]>([])
const searchKeyword = ref('') 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 formRef = ref<FormInstance>()
const form = reactive({ const form = reactive({
bom_prefix: '', // 自动生成的父件规格前缀 bom_prefix: '', // 自动生成的父件规格前缀
@ -229,7 +266,9 @@ const fetchBomList = async () => {
try { try {
const res = await getBomList({ keyword: searchKeyword.value }) const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) bomList.value = res.data if (res.code === 200) bomList.value = res.data
} catch (error) { ElMessage.error('网络错误') } } catch (error) {
// 错误已由全局拦截器统一处理
}
finally { loading.value = false } finally { loading.value = false }
} }
@ -237,7 +276,9 @@ const fetchMaterialOptions = async () => {
try { try {
const res = await getMaterialBaseList() const res = await getMaterialBaseList()
if (res.code === 200) materialOptions.value = res.data 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 form.bom_suffix = bomNo
} }
} }
} catch (e) { ElMessage.error('获取详情失败') } } catch (e) {
// 错误已由全局拦截器统一处理
}
} }
const handleDelete = (row: BomItem) => { const handleDelete = (row: BomItem) => {
@ -353,7 +396,9 @@ const submitForm = async () => {
dialogVisible.value = false dialogVisible.value = false
fetchBomList() fetchBomList()
} else { ElMessage.error(res.msg || '保存失败') } } else { ElMessage.error(res.msg || '保存失败') }
} catch (e) { ElMessage.error('网络错误') } } catch (e) {
// 错误已由全局拦截器统一处理
}
finally { saving.value = false } finally { saving.value = false }
}) })
} }

View File

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

View File

@ -71,7 +71,7 @@
<el-icon style="margin-right: 5px"><Download /></el-icon>导出库存统计 <el-icon style="margin-right: 5px"><Download /></el-icon>导出库存统计
</el-button> </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-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button> </el-button>
@ -98,18 +98,18 @@
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px"> <div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置 列展示设置
</div> </div>
<el-checkbox v-model="columns.id.visible" label="ID" /> <el-checkbox v-model="columns.id.visible" label="ID" :disabled="!userStore.hasPermission(permissionMap.id)" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" /> <el-checkbox v-model="columns.companyName.visible" label="所属公司" :disabled="!userStore.hasPermission(permissionMap.companyName)" />
<el-checkbox v-model="columns.name.visible" label="名称" /> <el-checkbox v-model="columns.name.visible" label="名称" :disabled="!userStore.hasPermission(permissionMap.name)" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" /> <el-checkbox v-model="columns.commonName.visible" label="俗名" :disabled="!userStore.hasPermission(permissionMap.commonName)" />
<el-checkbox v-model="columns.category.visible" label="类别" /> <el-checkbox v-model="columns.category.visible" label="类别" :disabled="!userStore.hasPermission(permissionMap.category)" />
<el-checkbox v-model="columns.type.visible" label="类型" /> <el-checkbox v-model="columns.type.visible" label="类型" :disabled="!userStore.hasPermission(permissionMap.type)" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" /> <el-checkbox v-model="columns.spec.visible" label="规格型号" :disabled="!userStore.hasPermission(permissionMap.spec)" />
<el-checkbox v-model="columns.unit.visible" label="单位" /> <el-checkbox v-model="columns.unit.visible" label="单位" :disabled="!userStore.hasPermission(permissionMap.unit)" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" /> <el-checkbox v-model="columns.inventory.visible" label="库存数" :disabled="!userStore.hasPermission(permissionMap.inventory)" />
<el-checkbox v-model="columns.available.visible" label="可用数" /> <el-checkbox v-model="columns.available.visible" label="可用数" :disabled="!userStore.hasPermission(permissionMap.available)" />
<el-checkbox v-model="columns.files.visible" label="资料" /> <el-checkbox v-model="columns.files.visible" label="资料" :disabled="!userStore.hasPermission(permissionMap.files)" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" /> <el-checkbox v-model="columns.isEnabled.visible" label="状态" :disabled="!userStore.hasPermission(permissionMap.isEnabled)" />
</div> </div>
</el-popover> </el-popover>
</div> </div>
@ -121,6 +121,7 @@
border border
stripe stripe
:size="tableSize" :size="tableSize"
@sort-change="handleSortChange"
style="width: 100%; margin-top: 15px" 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" /> <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.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.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 }"> <template #default="{ row }">
<span :style="{ fontWeight: 'bold', color: row.inventoryCount > 0 ? '#67C23A' : '#909399' }"> <span :style="{ fontWeight: 'bold', color: row.inventoryCount > 0 ? '#67C23A' : '#909399' }">
{{ row.inventoryCount }} {{ row.inventoryCount }}
@ -157,7 +158,7 @@
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : '#909399' }"> <span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : '#909399' }">
{{ row.availableCount }} {{ row.availableCount }}
@ -210,14 +211,15 @@
:active-value="1" :active-value="1"
:inactive-value="0" :inactive-value="0"
:loading="scope.row.statusLoading" :loading="scope.row.statusLoading"
:disabled="!userStore.hasPermission('material_list:operation')"
@change="handleStatusChange(scope.row)" @change="handleStatusChange(scope.row)"
/> />
</template> </template>
</el-table-column> </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"> <template #default="scope">
<el-button link type="primary" size="small" @click="handleEdit(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 link type="danger" size="small" @click="handleDelete(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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -246,12 +248,12 @@
<el-row> <el-row>
<el-col :span="12"> <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-input v-model="form.name" placeholder="内部名称" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <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-input v-model="form.commonName" placeholder="标准名称" />
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -259,7 +261,7 @@
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="所属公司" prop="companyName"> <el-form-item label="所属公司" prop="companyName" v-if="hasFieldPermission('companyName')">
<el-autocomplete <el-autocomplete
v-model="form.companyName" v-model="form.companyName"
:fetch-suggestions="querySearchCompany" :fetch-suggestions="querySearchCompany"
@ -270,7 +272,7 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <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;"> <div style="display: flex; width: 100%; align-items: center;">
<el-cascader <el-cascader
v-model="tempCategoryPrefix" v-model="tempCategoryPrefix"
@ -298,7 +300,7 @@
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="类型" prop="type"> <el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
<el-autocomplete <el-autocomplete
v-model="form.type" v-model="form.type"
:fetch-suggestions="querySearchType" :fetch-suggestions="querySearchType"
@ -309,7 +311,7 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <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-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -317,7 +319,7 @@
<el-row> <el-row>
<el-col :span="12"> <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-input v-model="form.unit" placeholder=": , , " />
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -329,7 +331,7 @@
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="产品图" prop="generalImage"> <el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
<div class="upload-container"> <div class="upload-container">
<el-upload <el-upload
v-model:file-list="fileListImage" v-model:file-list="fileListImage"
@ -357,7 +359,7 @@
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="说明书" prop="generalManual"> <el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
<div class="upload-container"> <div class="upload-container">
<el-upload <el-upload
v-model:file-list="fileListManual" v-model:file-list="fileListManual"
@ -385,7 +387,7 @@
</el-input> </el-input>
</el-form-item> </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-group v-model="form.isEnabled">
<el-radio :value="1">启用</el-radio> <el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</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 { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'; import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus'; import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
import { import {
listMaterialBase, listMaterialBase,
@ -432,6 +435,8 @@ import {
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'; import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
const userStore = useUserStore();
// --- 类型定义 --- // --- 类型定义 ---
interface MaterialBaseVO { interface MaterialBaseVO {
id: number; id: number;
@ -459,6 +464,8 @@ interface QueryParams {
type: string; type: string;
company: string; company: string;
isEnabled?: number; isEnabled?: number;
orderByColumn: string;
isAsc: string | undefined;
} }
interface CascaderOption { interface CascaderOption {
@ -501,6 +508,52 @@ const columns = reactive({
isEnabled: { visible: true } 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 companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]); const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]); const typeOptions = ref<string[]>([]);
@ -516,7 +569,9 @@ const queryParams = reactive<QueryParams>({
category: '', category: '',
type: '', type: '',
company: '', company: '',
isEnabled: undefined isEnabled: undefined,
orderByColumn: '',
isAsc: undefined
}); });
// --- 弹窗与表单相关 --- // --- 弹窗与表单相关 ---
@ -704,6 +759,18 @@ const handleInputSearch = () => {
}, 500); }, 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 = () => { const handleQuery = () => {
queryParams.pageNum = 1; queryParams.pageNum = 1;
getList(); getList();
@ -715,6 +782,8 @@ const resetQuery = () => {
queryParams.type = ''; queryParams.type = '';
queryParams.company = ''; queryParams.company = '';
queryParams.isEnabled = undefined; queryParams.isEnabled = undefined;
queryParams.orderByColumn = '';
queryParams.isAsc = undefined;
handleQuery(); handleQuery();
}; };
@ -988,6 +1057,8 @@ const handleCameraConfirm = async (file: File) => {
}; };
onMounted(() => { onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions();
getList(); getList();
getOptionsList(); getOptionsList();
}); });

View File

@ -8,14 +8,14 @@
<span class="subtitle">(请添加需要出库的物品)</span> <span class="subtitle">(请添加需要出库的物品)</span>
</div> </div>
<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>
<el-button type="warning" :icon="List" @click="openBomSelect"> <el-button v-if="userStore.hasPermission('outbound_selection:operation')" type="warning" :icon="List" @click="openBomSelect">
BOM 套餐添加 BOM 套餐添加
</el-button> </el-button>
<el-divider direction="vertical" /> <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> </el-button>
</div> </div>
@ -71,6 +71,7 @@
size="small" size="small"
style="width: 100%" style="width: 100%"
controls-position="right" controls-position="right"
:disabled="!userStore.hasPermission('outbound_selection:operation')"
@change="(val) => handleMainQuantityChange(val, row)" @change="(val) => handleMainQuantityChange(val, row)"
/> />
</template> </template>
@ -78,7 +79,7 @@
<el-table-column label="操作" width="80" align="center" fixed="right"> <el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -133,6 +134,7 @@
size="small" size="small"
style="width: 100%" style="width: 100%"
placeholder="0" placeholder="0"
:disabled="!userStore.hasPermission('outbound_selection:operation')"
@click.stop @click.stop
@change="(val) => handleManualQuantityChange(val, row)" @change="(val) => handleManualQuantityChange(val, row)"
/> />
@ -144,7 +146,7 @@
已勾选 {{ tempSelection.length }} 已勾选 {{ tempSelection.length }}
</span> </span>
<el-button @click="manualDialogVisible = false">取消</el-button> <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> </template>
</el-dialog> </el-dialog>
@ -161,7 +163,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="生产套数"> <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-item>
</el-form> </el-form>
<div style="margin-left: 100px; color: #909399; font-size: 12px;"> <div style="margin-left: 100px; color: #909399; font-size: 12px;">
@ -169,7 +171,7 @@
</div> </div>
<template #footer> <template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button> <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> </template>
</el-dialog> </el-dialog>
@ -204,11 +206,11 @@
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="previewVisible = false">取消</el-button> <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 导出 Excel
</el-button> </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) 确认打印 (A4)
</el-button> </el-button>
</span> </span>
@ -283,6 +285,9 @@ import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
import { ElMessage, ElTable, ElMessageBox } from 'element-plus' import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
import { getAllStock, printSelectionList } from '@/api/inbound/stock' import { getAllStock, printSelectionList } from '@/api/inbound/stock'
import { getBomList, getBomDetail } from '@/api/bom' import { getBomList, getBomDetail } from '@/api/bom'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态变量 --- // --- 状态变量 ---
const selectedItems = ref<any[]>([]) const selectedItems = ref<any[]>([])

View File

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

View File

@ -20,7 +20,7 @@
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
/> />
<el-button type="primary" class="filter-item" style="margin-left: 10px;" @click="fetchData">查询</el-button> <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> </div>
<el-table <el-table
@ -35,18 +35,18 @@
<div style="padding: 10px 40px; background: #fafafa;"> <div style="padding: 10px 40px; background: #fafafa;">
<h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4> <h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4>
<el-table :data="props.row.items" border size="small"> <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 v-if="hasColumnPermission('name')" prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="material_type" label="类型" width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('material_type')" prop="material_type" label="类型" width="120" show-overflow-tooltip />
<el-table-column prop="category" label="类别" width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('category')" 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('spec_model')" prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="100" /> <el-table-column v-if="hasColumnPermission('quantity')" prop="quantity" label="数量" width="100" />
<el-table-column prop="unit_price" label="单价" width="120"> <el-table-column v-if="hasColumnPermission('unit_price')" prop="unit_price" label="单价" width="120">
<template #default="{row}">¥{{ row.unit_price }}</template> <template #default="{row}">¥{{ row.unit_price }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="subtotal" label="小计"> <el-table-column v-if="hasColumnPermission('subtotal')" prop="subtotal" label="小计">
<template #default="{row}"> <template #default="{row}">
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span> <span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
</template> </template>
@ -56,33 +56,33 @@
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<span>{{ row.outbound_time ? row.outbound_time.substring(0, 16) : '' }}</span> <span>{{ row.outbound_time ? row.outbound_time.substring(0, 16) : '' }}</span>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<el-tag :type="getTagType(row.outbound_type)">{{ formatType(row.outbound_type) }}</el-tag> <el-tag :type="getTagType(row.outbound_type)">{{ formatType(row.outbound_type) }}</el-tag>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span> <span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span>
</template> </template>
</el-table-column> </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 }"> <template #default="{ row }">
<div v-if="row.signature_path" class="signature-cell"> <div v-if="row.signature_path" class="signature-cell">
<el-image <el-image
@ -121,6 +121,39 @@
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { getOutboundList } from '@/api/outbound' import { getOutboundList } from '@/api/outbound'
import { Picture } from '@element-plus/icons-vue' 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 list = ref([])
const total = ref(0) const total = ref(0)

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="buy-module"> <div class="buy-module">
<div class="header-container"> <div class="header-container" style="flex-wrap: wrap;">
<div class="search-form-area"> <div class="search-form-area" style="flex-wrap: wrap;">
<el-select <el-select
v-model="queryParams.company" v-model="queryParams.company"
@ -55,8 +55,8 @@
<el-button class="reset-btn" @click="resetQuery">重置</el-button> <el-button class="reset-btn" @click="resetQuery">重置</el-button>
</div> </div>
<div class="right-actions"> <div class="right-actions" style="flex-wrap: wrap;">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="add-btn">新增</el-button> <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-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click"> <el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
@ -67,13 +67,13 @@
<div class="col-group-title">基础信息</div> <div class="col-group-title">基础信息</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop"> <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-col>
</el-row> </el-row>
<div class="col-group-title" style="margin-top:10px">库存与商务</div> <div class="col-group-title" style="margin-top:10px">库存与商务</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop"> <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-col>
</el-row> </el-row>
</el-checkbox-group> </el-checkbox-group>
@ -167,7 +167,7 @@
</el-table-column> </el-table-column>
</template> </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 }"> <template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)"> <el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon> 打印 <el-icon><Printer/></el-icon> 打印
@ -197,7 +197,7 @@
<el-dialog <el-dialog
v-model="visible" v-model="visible"
:title="dialogStatus === 'create' ? '新增采购入库' : '编辑入库信息'" :title="dialogStatus === 'create' ? '新增采购入库' : '编辑入库信息'"
width="1000px" :width="'min(1000px, 95vw)'"
top="4vh" top="4vh"
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
@ -272,12 +272,12 @@
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="20"> <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="所属公司" 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="名称"><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_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="类型"><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('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="类别"><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('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="规格型号"><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('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="单位"><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('unit')"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
</el-row> </el-row>
</div> </div>
</div> </div>
@ -296,9 +296,6 @@
<el-col :span="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-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>
<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-col :span="6">
<el-form-item label="库位" prop="warehouse_location"> <el-form-item label="库位" prop="warehouse_location">
<el-autocomplete <el-autocomplete
@ -346,7 +343,7 @@
<el-row :gutter="20" style="margin-top: 10px;"> <el-row :gutter="20" style="margin-top: 10px;">
<el-col :span="6"> <el-col :span="6">
<el-form-item label="入库数量" prop="in_quantity"> <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-form-item>
</el-col> </el-col>
@ -423,31 +420,72 @@
</el-row> </el-row>
<div class="divider-text">商务与采购信息</div> <div class="divider-text">商务与采购信息</div>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="6"> <el-col :span="8">
<el-form-item label="币种"> <el-form-item label="币种">
<el-autocomplete v-model="form.currency" :fetch-suggestions="querySearchCurrency" placeholder="币种" style="width: 100%" :trigger-on-focus="true"> <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> <template #default="{ item }"><span>{{ item.value }}</span><span style="float:right; color:#999; font-size:12px">{{ item.desc }}</span></template>
</el-autocomplete> </el-autocomplete>
</el-form-item> </el-form-item>
</el-col> </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="8">
<el-form-item label="汇率">
<el-col :span="6"> <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-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="0%" :value="0" />
<el-option label="1%" :value="1" /> <el-option label="1%" :value="1" />
<el-option label="13%" :value="13" /> <el-option label="13%" :value="13" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </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>
<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-col :span="8">
<el-form-item label="供应商"> <el-form-item label="供应商">
<el-autocomplete <el-autocomplete
@ -587,6 +625,7 @@ import {
} from '@/api/inbound/buy' } from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { useUserStore } from '@/stores/user'
// ------------------------------------ // ------------------------------------
// 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框) // 自定义指令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 loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
@ -689,7 +778,7 @@ const stockColumns = [
{prop: 'tax_rate', label: '税率', minWidth: '80'}, {prop: 'tax_rate', label: '税率', minWidth: '80'},
{prop: 'unit_price', label: '不含税单价', minWidth: '120'}, {prop: 'unit_price', label: '不含税单价', minWidth: '120'},
{prop: 'total_price', label: '总价', minWidth: '120'}, {prop: 'total_price', label: '不含税总价', minWidth: '120'},
{prop: 'currency', label: '币种', minWidth: '80'}, {prop: 'currency', label: '币种', minWidth: '80'},
{prop: 'exchange_rate', label: '汇率', minWidth: '80'}, {prop: 'exchange_rate', label: '汇率', minWidth: '80'},
{prop: 'supplier_name', label: '供应商', minWidth: '150'}, {prop: 'supplier_name', label: '供应商', minWidth: '150'},
@ -701,6 +790,78 @@ const stockColumns = [
{prop: 'inspection_report', label: '检测报告', minWidth: '100'} {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 allColumns = [...baseColumns, ...stockColumns]
const defaultColumns = [ const defaultColumns = [
@ -719,7 +880,9 @@ const form = reactive({
material_name: '', spec_model: '', category: '', unit: '', material_type: '', material_name: '', spec_model: '', category: '', unit: '', material_type: '',
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', 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, tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '',
@ -727,7 +890,6 @@ const form = reactive({
print_copies: 1 print_copies: 1
}) })
// ------------------------------------ // ------------------------------------
// 建议/Autocomplete 逻辑 // 建议/Autocomplete 逻辑
// ------------------------------------ // ------------------------------------
@ -916,7 +1078,45 @@ const handleEntryModeChange = (val: string) => {
if(formRef.value) formRef.value.clearValidate('batch_number') 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 () => { const fetchData = async () => {
loading.value = true 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, 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, 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), 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), tax_rate: Number(row.tax_rate),
currency: row.currency, exchange_rate: Number(row.exchange_rate), currency: row.currency, exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email, supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email,
source_link: row.source_link, detail_link: row.detail_link, source_link: row.source_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || [] 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) })) arrivalFileList.value = form.arrival_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const reports = form.inspection_report || [] const reports = form.inspection_report || []
const reportImgs = reports.filter(r => !isExternalLink(r)) const reportImgs = reports.filter(r => !isExternalLink(r))
@ -1004,7 +1211,13 @@ const submitForm = async () => {
const onlyImages = finalReportList.filter(item => !isExternalLink(item)) const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value) 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 { try {
if (dialogStatus.value === 'create') { if (dialogStatus.value === 'create') {
const res: any = await createBuyInbound(payload) const res: any = await createBuyInbound(payload)
@ -1170,16 +1383,26 @@ const resetForm = () => {
id: undefined, base_id: undefined, id: undefined, base_id: undefined,
company_name: '', 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: '', 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, tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [], currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [],
print_copies: 1 print_copies: 1
}) })
} }
const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' } 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(() => { onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData() fetchData()
fetchOptions() fetchOptions()
}) })
@ -1360,6 +1583,11 @@ onMounted(() => {
.camera-card:hover { border-color: #409EFF; color: #409EFF; } .camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .camera-card .el-icon { font-size: 24px; }
/* 自定义千分位无箭头输入框样式,用于强迫症优化显示 */
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
</style> </style>
<style> <style>

View File

@ -16,22 +16,49 @@
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / SN / 工单..." placeholder="请输入名称或规格"
class="filter-item-input" class="filter-item-input"
clearable clearable
@clear="fetchData" @clear="fetchData"
@keyup.enter="fetchData" @keyup.enter="fetchData"
style="width: 260px;" style="width: 240px;"
> >
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </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 <el-select
v-model="queryParams.statuses" v-model="queryParams.statuses"
multiple multiple
collapse-tags collapse-tags
placeholder="状态筛选" placeholder="状态筛选"
style="width: 220px;" style="width: 200px; margin-left: 10px;"
@change="fetchData" @change="fetchData"
> >
<el-option label="在库" value="在库" /> <el-option label="在库" value="在库" />
@ -41,13 +68,13 @@
</div> </div>
<div class="right-tools"> <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-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click"> <el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template> <template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-row :gutter="10"> <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-row>
</el-checkbox-group> </el-checkbox-group>
</el-popover> </el-popover>
@ -79,8 +106,7 @@
</template> </template>
<template #default="scope" v-else-if="col.prop === 'company_name'"> <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>{{ scope.row.company_name || '-' }}</span>
<span v-else>-</span>
</template> </template>
<template #default="scope" v-else-if="['serial_number'].includes(col.prop)"> <template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
@ -130,14 +156,14 @@
</el-link> </el-link>
</template> </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> <span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template> </template>
</el-table-column> </el-table-column>
</template> </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 }"> <template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)"> <el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon> <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-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"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
@ -163,7 +189,7 @@
</div> </div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <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-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
@ -171,7 +197,7 @@
remote remote
reserve-keyword reserve-keyword
clearable clearable
placeholder="搜名称/规格..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
@ -179,7 +205,7 @@
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option
v-loadmore="loadMoreMaterials" v-loadmore="loadMoreMaterials"
popper-class="long-dropdown" popper-class="product-dropdown"
> >
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id"> <el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
@ -203,20 +229,20 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </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"> <span class="search-tip">
<el-icon><InfoFilled /></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索 <el-icon><InfoFilled /></el-icon> 支持名称、规格型号、公司名称模糊搜索
</span> </span>
</el-col> </el-col>
</el-row> </el-row>
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="24"> <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="所属公司" 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="名称"><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_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="规格"><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('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="单位"><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('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="类型"><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('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="类别"><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('category')"><el-input v-model="form.category" readonly class="is-text-view" /></el-form-item></el-col>
</el-row> </el-row>
</div> </div>
</div> </div>
@ -227,7 +253,6 @@
<div class="card-content"> <div class="card-content">
<el-row :gutter="24"> <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="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="库位" 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-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> </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-autocomplete v-model="form.production_manager" :fetch-suggestions="querySearchManager" placeholder="输入或选择负责人" style="width: 100%" clearable :trigger-on-focus="true" @select="handleManagerSelect"/>
</el-form-item> </el-form-item>
</el-col> </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>
<el-row :gutter="24"> <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-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-form-item>
</el-col> </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-row>
<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 :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>
<el-row :gutter="24" style="margin-top:10px"> <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> <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, deleteProductInbound,
searchMaterialBase, searchMaterialBase,
searchBom, searchBom,
getFilterOptions // [新增] getFilterOptions,
getManagerHistory, // [新增]
calculateBomCost
} from '@/api/inbound/product' } from '@/api/inbound/product'
import { uploadFile, deleteFile } from '@/api/inbound/buy' import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { getLabelPreview, executePrint } from '@/api/common/print' import { getLabelPreview, executePrint } from '@/api/common/print'
import { useUserStore } from '@/stores/user'
// ------------------------------------ // ------------------------------------
// v-loadmore // v-loadmore
@ -439,7 +488,8 @@ import { getLabelPreview, executePrint } from '@/api/common/print'
const vLoadmore = { const vLoadmore = {
mounted(el: any, binding: any) { mounted(el: any, binding: any) {
const checkAndBind = () => { 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')) { if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true') dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) { dropDownWrap.addEventListener('scroll', function (this: any) {
@ -455,6 +505,7 @@ const vLoadmore = {
} }
} }
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
@ -464,7 +515,7 @@ const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() 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 categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增] const companyOptions = ref<string[]>([]) // [新增]
@ -518,16 +569,81 @@ const allColumns = [
{ prop: 'bom_code', label: 'BOM', minWidth: '100' }, { prop: 'bom_code', label: 'BOM', minWidth: '100' },
{ prop: 'production_manager', label: '负责人', minWidth: '100' }, { prop: 'production_manager', label: '负责人', minWidth: '100' },
{ prop: 'raw_material_cost', 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: 'inbound_date', label: '生产日期', minWidth: '120' },
{ prop: 'detail_link', label: '详情', minWidth: '100' } { 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 defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref(defaultVisibleCols) const visibleColumnProps = ref(defaultVisibleCols)
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined, id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增] company_name: '', // [新增]
material_name: '', spec_model: '', material_type: '', category: '', unit: '', material_name: '', spec_model: '', material_type: '', category: '', unit: '',
sku: '', barcode: '', serial_number: '', in_date: '', sku: '', barcode: '', serial_number: '', in_date: '',
@ -535,7 +651,10 @@ const form = reactive({
warehouse_location: '', status: '在库', quality_status: '合格', warehouse_location: '', status: '在库', quality_status: '合格',
bom_code: '', bom_version: '', work_order_code: '', order_id: '', bom_code: '', bom_version: '', work_order_code: '', order_id: '',
production_manager: '', production_time_range: [] as string[], 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: '' 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 || [] bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false } } finally { bomSearchLoading.value = false }
} }
const handleBomSelect = (val: string) => { const handleBomSelect = async (val: string) => {
if (!val) { if (!val) {
form.bom_code = '' form.bom_code = ''
form.bom_version = '' form.bom_version = ''
@ -558,6 +677,65 @@ const handleBomSelect = (val: string) => {
const [code, version] = val.split('###') const [code, version] = val.split('###')
form.bom_code = code form.bom_code = code
form.bom_version = version 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('') } const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
@ -601,9 +779,9 @@ const handleSearchMaterial = async (query: string) => {
try { try {
const res: any = await searchMaterialBase(query, 1) 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 materialOptions.value = apiResults
hasNextPage.value = res.has_next hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false } } finally { searchLoading.value = false }
} }
@ -613,10 +791,10 @@ const loadMoreMaterials = async () => {
searchPage.value += 1 searchPage.value += 1
try { try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value) const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) { if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false})) const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems) materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next hasNextPage.value = res.data.has_next
} else { } else {
hasNextPage.value = false hasNextPage.value = false
} }
@ -640,12 +818,19 @@ const onMaterialSelected = (val: number) => {
} }
// ------------------------------------ // ------------------------------------
// Autocomplete (Manager) - 后端驱动 // Autocomplete (Manager) - 后端历史记录驱动 (已修改为全局)
// ------------------------------------ // ------------------------------------
const querySearchManager = async (query: string, cb: any) => { 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) => { const handleManagerSelect = (item: any) => {
form.production_manager = item.value
} }
const fetchData = async () => { const fetchData = async () => {
@ -696,10 +881,14 @@ const handleUpdate = (row: any) => {
quality_report_link: row.quality_report_link || [], quality_report_link: row.quality_report_link || [],
inspection_report_link: row.inspection_report_link || [], inspection_report_link: row.inspection_report_link || [],
in_quantity: Number(row.qty_inbound), in_quantity: Number(row.qty_inbound),
raw_material_cost: Number(row.raw_material_cost), raw_material_cost: (row.raw_material_cost !== null && row.raw_material_cost !== undefined) ? Number(row.raw_material_cost) : undefined,
manual_cost: Number(row.manual_cost), unit_total_cost: (row.unit_total_cost !== null && row.unit_total_cost !== undefined) ? Number(row.unit_total_cost) : undefined,
sale_price: Number(row.sale_price) 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 = [] } 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) })) productPhotoList.value = form.product_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const qReports = form.quality_report_link || [] const qReports = form.quality_report_link || []
@ -802,7 +991,17 @@ const submitForm = async () => {
const iImages = iList.filter(item => !isExternalLink(item)) const iImages = iList.filter(item => !isExternalLink(item))
if (inspection_url.value && !iList.includes(inspection_url.value)) iImages.push(inspection_url.value) if (inspection_url.value && !iList.includes(inspection_url.value)) iImages.push(inspection_url.value)
else if (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 delete payload.production_time_range
try { try {
if(dialogStatus.value === 'create') { 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 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 = () => { const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = '' 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 getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info') const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info')
const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}` const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
onMounted(() => { onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData() fetchData()
fetchOptions() 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> </script>
<style scoped> <style scoped>
.product-module { background: #f5f7fa; padding: 20px; min-height: 100vh; } .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); } .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; } .left-tools { display: flex; gap: 10px; align-items: center; flex: 1; flex-wrap: wrap; }
.right-tools { display: flex; gap: 10px; align-items: center; } .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); } .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; } :deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; } .tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
@ -893,16 +1101,24 @@ onMounted(() => {
.filter-item-input { /* 宽度已在行内样式控制 */ } .filter-item-input { /* 宽度已在行内样式控制 */ }
.action-btn { font-weight: 500; } .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; } .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__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; } .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>
<style> <style>
.long-dropdown { width: 580px !important; } .product-dropdown { width: 580px !important; }
.long-dropdown .el-select-dropdown__wrap { max-height: 320px !important; } .product-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
.long-dropdown .el-input__suffix { z-index: 10; } .product-dropdown .el-input__suffix { z-index: 10; }
</style> </style>

View File

@ -69,7 +69,7 @@
</div> </div>
<div class="right-tools"> <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-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click"> <el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
@ -80,13 +80,13 @@
<div class="col-group-title">基础信息</div> <div class="col-group-title">基础信息</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop"> <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-col>
</el-row> </el-row>
<div class="col-group-title" style="margin-top:10px">生产与库存</div> <div class="col-group-title" style="margin-top:10px">生产与库存</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop"> <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-col>
</el-row> </el-row>
</el-checkbox-group> </el-checkbox-group>
@ -119,8 +119,7 @@
</template> </template>
<template #default="scope" v-else-if="col.prop === 'company_name'"> <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>{{ scope.row.company_name || '-' }}</span>
<span v-else>-</span>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'sn_bn'"> <template #default="scope" v-else-if="col.prop === 'sn_bn'">
@ -183,13 +182,13 @@
</el-link> </el-link>
</template> </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> <span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template> </template>
</el-table-column> </el-table-column>
</template> </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 }"> <template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)"> <el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon> 打印 <el-icon><Printer/></el-icon> 打印
@ -219,7 +218,7 @@
<el-dialog <el-dialog
v-model="visible" v-model="visible"
:title="dialogStatus === 'create' ? '新增半成品入库' : '编辑半成品信息'" :title="dialogStatus === 'create' ? '新增半成品入库' : '编辑半成品信息'"
width="1050px" width="min(1000px, 95vw)"
top="5vh" top="5vh"
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
@ -293,12 +292,12 @@
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="24"> <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="所属公司" 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="名称"><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_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="规格型号"><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('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="单位"><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('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="类别"><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('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="类型"><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('material_type')"><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
</el-row> </el-row>
</div> </div>
</div> </div>
@ -314,7 +313,6 @@
<el-row :gutter="24"> <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="编码/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="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-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> </el-row>
@ -467,11 +465,23 @@
</el-col> </el-col>
</el-row> </el-row>
<div class="divider-text">成本核算 (单件)</div> <div class="divider-text">成本核算</div>
<el-row :gutter="24"> <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-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-form-item label="原材料成本">
<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-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>
<el-row :gutter="24" style="margin-top:10px"> <el-row :gutter="24" style="margin-top:10px">
@ -528,11 +538,14 @@ import {
deleteSemiInbound, deleteSemiInbound,
searchMaterialBase, searchMaterialBase,
searchBom, searchBom,
getFilterOptions getFilterOptions,
getManagerHistory, // [新增]
calculateBomCost
} from '@/api/inbound/semi' } from '@/api/inbound/semi'
import { uploadFile, deleteFile } from '@/api/inbound/buy' import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
import { useUserStore } from '@/stores/user'
// ------------------------------------ // ------------------------------------
// 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框) // 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框)
@ -559,6 +572,7 @@ const vLoadmore = {
// ------------------------------------ // ------------------------------------
// 状态与变量 // 状态与变量
// ------------------------------------ // ------------------------------------
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
@ -629,8 +643,8 @@ const stockColumns = [
{prop: 'bom_version', label: 'BOM版本', minWidth: '90'}, {prop: 'bom_version', label: 'BOM版本', minWidth: '90'},
{prop: 'work_order_code', label: '工单号', minWidth: '120'}, {prop: 'work_order_code', label: '工单号', minWidth: '120'},
{prop: 'raw_material_cost', label: '原料成本', minWidth: '100'}, {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_manager', label: '生产负责人', minWidth: '100'},
{prop: 'production_start_time', label: '生产开始', minWidth: '160'}, {prop: 'production_start_time', label: '生产开始', minWidth: '160'},
{prop: 'production_end_time', label: '生产结束', minWidth: '160'}, {prop: 'production_end_time', label: '生产结束', minWidth: '160'},
@ -640,13 +654,89 @@ const stockColumns = [
] ]
const allColumns = [...baseColumns, ...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 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 visibleColumnProps = ref(defaultColumns)
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增] 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: '' 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 || [] bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false } } finally { bomSearchLoading.value = false }
} }
const handleBomSelect = (val: string) => { const handleBomSelect = async (val: string) => {
// val 格式为 bom_no###version // val 格式为 bom_no###version
if (!val) { if (!val) {
form.bom_code = '' form.bom_code = ''
@ -669,15 +759,33 @@ const handleBomSelect = (val: string) => {
const [code, version] = val.split('###') const [code, version] = val.split('###')
form.bom_code = code form.bom_code = code
form.bom_version = version 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) => { 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) => { const handleManagerSelect = (item: any) => {
form.production_manager = item.value
} }
// ------------------------------------ // ------------------------------------
@ -700,9 +808,9 @@ const handleSearchMaterial = async (query: string) => {
try { try {
const res: any = await searchMaterialBase(query, 1) 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 materialOptions.value = apiResults
hasNextPage.value = res.has_next hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false } } finally { searchLoading.value = false }
} }
@ -712,10 +820,10 @@ const loadMoreMaterials = async () => {
searchPage.value += 1 searchPage.value += 1
try { try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value) const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) { if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false})) const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems) materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next hasNextPage.value = res.data.has_next
} else { } else {
hasNextPage.value = false hasNextPage.value = false
} }
@ -765,6 +873,50 @@ const rules = {
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}] 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 // 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') } 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') } 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 () => { const fetchData = async () => {
loading.value = true loading.value = true
@ -847,12 +998,17 @@ const handleUpdate = (row: any) => {
warehouse_location: row.warehouse_loc, status: row.status, quality_status: row.quality_status, 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), 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, 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_manager: row.production_manager,
production_time_range: (row.production_start_time && row.production_end_time) ? [row.production_start_time, row.production_end_time] : [], production_time_range: (row.production_start_time && row.production_end_time) ? [row.production_start_time, row.production_end_time] : [],
detail_link: row.detail_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], quality_report_link: row.quality_report_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) })) arrivalFileList.value = form.arrival_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const reports = form.quality_report_link || [] const reports = form.quality_report_link || []
const reportImgs = reports.filter(r => !isExternalLink(r)) 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) if (quality_report_url.value && !finalReportList.includes(quality_report_url.value)) finalReportList.push(quality_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item)) const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (quality_report_url.value) onlyImages.push(quality_report_url.value) 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 delete payload.production_time_range
try { try {
if (dialogStatus.value === 'create') { if (dialogStatus.value === 'create') {
@ -978,13 +1143,17 @@ const resetForm = () => {
Object.assign(form, { Object.assign(form, {
id: undefined, base_id: undefined, id: undefined, base_id: undefined,
company_name: '', // [新增] 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 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 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(() => { onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData() fetchData()
fetchOptions() fetchOptions()
}) })
@ -992,9 +1161,9 @@ onMounted(() => {
<style scoped> <style scoped>
.semi-module { background: #f5f7fa; padding: 20px; min-height: 100vh; } .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); } .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; } .left-tools { display: flex; gap: 10px; align-items: center; flex: 1; flex-wrap: wrap; }
.right-tools { display: flex; gap: 10px; align-items: center; } .right-tools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.action-btn { font-weight: 500; } .action-btn { font-weight: 500; }
.modern-table { border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); } .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; } :deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; font-weight: 600; height: 50px; }
@ -1056,6 +1225,9 @@ onMounted(() => {
.opt-spec { color: #999; font-size: 12px; } .opt-spec { color: #999; font-size: 12px; }
.opt-tags { display: flex; gap: 5px; flex-shrink: 0; } .opt-tags { display: flex; gap: 5px; flex-shrink: 0; }
.company-tag { font-weight: bold; } .company-tag { font-weight: bold; }
/* 左对齐数字框 */
:deep(.el-input-number .el-input__inner) { text-align: left; }
</style> </style>
<style> <style>

View File

@ -45,21 +45,21 @@
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</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-item>
</el-form> </el-form>
</div> </div>
<el-table :data="tableData" border stripe style="width: 100%;" v-loading="loading"> <el-table :data="tableData" border stripe style="width: 100%;" v-loading="loading">
<el-table-column prop="sku" label="SKU" width="200" /> <el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="200" />
<el-table-column prop="material_name" label="物料名称" /> <el-table-column v-if="hasColumnPermission('material_name')" prop="material_name" label="物料名称" />
<el-table-column prop="provider_name" label="服务商" width="150" /> <el-table-column v-if="hasColumnPermission('provider_name')" prop="provider_name" label="服务商" width="150" />
<el-table-column prop="sale_price" label="售价" width="120"> <el-table-column v-if="hasColumnPermission('sale_price')" prop="sale_price" label="售价" width="120">
<template #default="{row}">{{ row.sale_price.toFixed(2) }}</template> <template #default="{row}">{{ row.sale_price.toFixed(2) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="简介" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('description')" prop="description" label="简介" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="160" /> <el-table-column v-if="hasColumnPermission('created_at')" prop="created_at" label="创建时间" width="160" />
<el-table-column label="操作" width="180" fixed="right"> <el-table-column v-if="userStore.hasPermission('inbound_service:operation')" label="操作" width="180" fixed="right">
<template #default="{row}"> <template #default="{row}">
<el-button size="small" @click="handleEdit(row)">编辑</el-button> <el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(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"> <div class="read-only-grid" v-if="form.base_id">
<el-row :gutter="20"> <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="名称" 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="类型"><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('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="类别"><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('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="规格型号"><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('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="单位"><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('unit')"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
</el-row> </el-row>
</div> </div>
</div> </div>
@ -150,7 +150,7 @@
<span>2. 服务详情</span> <span>2. 服务详情</span>
</div> </div>
<div class="card-content"> <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 <el-input-number
v-model="form.sale_price" v-model="form.sale_price"
placeholder="请输入售价" placeholder="请输入售价"
@ -160,7 +160,7 @@
style="width: 100%;" style="width: 100%;"
/> />
</el-form-item> </el-form-item>
<el-form-item label="服务商" prop="provider_name"> <el-form-item label="服务商" prop="provider_name" v-if="hasFormFieldPermission('provider_name')">
<el-autocomplete <el-autocomplete
v-model="form.provider_name" v-model="form.provider_name"
:fetch-suggestions="querySearchProvider" :fetch-suggestions="querySearchProvider"
@ -171,7 +171,7 @@
@select="handleProviderSelect" @select="handleProviderSelect"
/> />
</el-form-item> </el-form-item>
<el-form-item label="简介" prop="description"> <el-form-item label="简介" prop="description" v-if="hasFormFieldPermission('description')">
<el-input <el-input
v-model="form.description" v-model="form.description"
type="textarea" type="textarea"
@ -198,6 +198,7 @@ import { ref, reactive, onMounted } from 'vue'
import { InfoFilled, Box, House } from '@element-plus/icons-vue' import { InfoFilled, Box, House } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { import {
getServiceList, getServiceList,
createService, createService,
@ -212,6 +213,53 @@ import {
type MaterialBaseItem type MaterialBaseItem
} from '@/api/inbound/service' } 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 tableData = ref<ServiceItem[]>([])
const loading = ref(false) const loading = ref(false)

View File

@ -10,12 +10,12 @@
<p class="subtitle">单件自动确认多件弹窗录入</p> <p class="subtitle">单件自动确认多件弹窗录入</p>
<div class="idle-actions"> <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>
<el-button <el-button
v-if="serverDraftCount > 0" v-if="serverDraftCount > 0 && userStore.hasPermission('inventory_stocktake:operation')"
type="warning" type="warning"
plain plain
size="large" 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-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> <el-tag v-else type="danger" size="small" effect="dark" round>同步失败</el-tag>
</div> </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> </el-button>
</div> </div>
@ -84,7 +84,7 @@
</el-button> </el-button>
</el-col> </el-col>
<el-col :span="12"> <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-button>
</el-col> </el-col>
@ -132,6 +132,7 @@
style="width: 100%" style="width: 100%"
ref="qtyInputRef" ref="qtyInputRef"
placeholder="请输入实际点数" placeholder="请输入实际点数"
class="large-control-input"
/> />
<p class="unit-text">单位: {{ currentItem.unit || '个' }}</p> <p class="unit-text">单位: {{ currentItem.unit || '个' }}</p>
</div> </div>
@ -139,7 +140,7 @@
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="showQtyDialog = false">取消</el-button> <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> </div>
</template> </template>
</el-dialog> </el-dialog>
@ -190,6 +191,7 @@
<el-table-column label="操作" width="90" align="center" fixed="right"> <el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button <el-button
v-if="userStore.hasPermission('inventory_stocktake:operation')"
type="primary" type="primary"
link link
icon="Edit" icon="Edit"
@ -237,7 +239,7 @@
<el-button @click="showFinishDialog = false">返回修改</el-button> <el-button @click="showFinishDialog = false">返回修改</el-button>
<div class="footer-right"> <div class="footer-right">
<el-button type="success" @click="exportToExcel" :icon="Download">导出Excel</el-button> <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>
</div> </div>
</template> </template>
@ -276,6 +278,8 @@ interface StockItem {
type?: string type?: string
category?: string category?: string
price?: number price?: number
source_table?: string
stock_id?: number
[key: string]: any [key: string]: any
} }
@ -292,6 +296,7 @@ const showQtyDialog = ref(false)
const allData = ref<StockItem[]>([]) const allData = ref<StockItem[]>([])
const scannedMap = ref<Map<string, number>>(new Map()) const scannedMap = ref<Map<string, number>>(new Map())
const borrowedQuantities = ref<Record<string, number>>({})
const filterType = ref('all') const filterType = ref('all')
const searchKeyword = ref('') const searchKeyword = ref('')
@ -306,6 +311,34 @@ const api = {
clearDraft: () => request({ url: '/v1/inbound/stock/draft/clear', method: 'post', data: { user_id: currentUser } }) 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 () => { onMounted(async () => {
await checkServerDraft() await checkServerDraft()
}) })
@ -380,7 +413,9 @@ const loadData = async () => {
qty_stock: stock, qty_stock: stock,
qty_actual: isScanned ? scannedMap.value.get(uuid)! : 0, qty_actual: isScanned ? scannedMap.value.get(uuid)! : 0,
scanned: isScanned, 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')) if (res.products) res.products.forEach((i: any) => processItem(i, 'product'))
allData.value = list allData.value = list
await fetchBorrowedQuantities(list)
} catch (e) { } catch (e) {
ElMessage.error('数据加载失败') ElMessage.error('数据加载失败')
} finally { loading.value = false } } finally { loading.value = false }
@ -470,7 +506,13 @@ const closeOverlays = () => {
const exportToExcel = () => { const exportToExcel = () => {
try { try {
// 1. 已盘点 Sheet // 1. 已盘点 Sheet
const scannedData = allData.value.filter(i => i.scanned).map(item => ({ 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.name,
'类型': item.type || item.material_type || '-', '类型': item.type || item.material_type || '-',
'类别': item.category || '-', '类别': item.category || '-',
@ -481,12 +523,17 @@ const exportToExcel = () => {
'单价': item.price || item.unit_price || 0, '单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any), '账面库存': parseFloat(item.qty_stock as any),
'实盘数量': item.qty_actual, '实盘数量': item.qty_actual,
'盘点结果': item.qty_stock === item.qty_actual ? '相符' : '差异', '借出未还数量': borrowedQty,
'差异数': item.qty_actual - item.qty_stock '盘点结果': result,
})) '差异数': diff
}
})
// 2. 未盘点 Sheet // 2. 未盘点 Sheet
const missingData = allData.value.filter(i => !i.scanned).map(item => ({ 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.name,
'类型': item.type || item.material_type || '-', '类型': item.type || item.material_type || '-',
'类别': item.category || '-', '类别': item.category || '-',
@ -496,8 +543,10 @@ const exportToExcel = () => {
'单位': item.unit || '个', '单位': item.unit || '个',
'单价': item.price || item.unit_price || 0, '单价': item.price || item.unit_price || 0,
'账面库存': parseFloat(item.qty_stock as any), '账面库存': parseFloat(item.qty_stock as any),
'借出未还数量': borrowedQty,
'状态': '未盘点' '状态': '未盘点'
})) }
})
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
const ws1 = XLSX.utils.json_to_sheet(scannedData) const ws1 = XLSX.utils.json_to_sheet(scannedData)
@ -506,7 +555,7 @@ const exportToExcel = () => {
const wscols = [ const wscols = [
{wch: 20}, {wch: 10}, {wch: 10}, {wch: 15}, {wch: 20}, {wch: 10}, {wch: 10}, {wch: 15},
{wch: 15}, {wch: 15}, {wch: 5}, {wch: 8}, {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 ws1['!cols'] = wscols
ws2['!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; } .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; } .dialog-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.footer-right { display: flex; gap: 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> </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> <template #header>
<div class="card-header"> <div class="card-header">
<span style="font-weight: bold;">员工账号管理</span> <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> </el-button>
</div> </div>
@ -16,23 +16,33 @@
border border
style="width: 100%" 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"> <template #default="scope">
<el-tag>{{ scope.row.department }}</el-tag> <el-tag>{{ scope.row.department }}</el-tag>
</template> </template>
</el-table-column> </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"> <template #default="scope">
{{ formatRole(scope.row.role) }} {{ formatRole(scope.row.role) }}
</template> </template>
</el-table-column> </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"> <template #default="scope">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button> <el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-popconfirm title="确定要删除该用户吗?" @confirm="handleDelete(scope.row)"> <el-popconfirm title="确定要删除该用户吗?" @confirm="handleDelete(scope.row)">
@ -57,20 +67,20 @@
:rules="rules" :rules="rules"
label-width="100px" label-width="100px"
> >
<el-form-item label="真实姓名" prop="cn_name"> <el-form-item label="真实姓名" prop="cn_name" v-if="hasFormFieldPermission('cn_name')">
<el-input <el-input
v-model="form.cn_name" v-model="form.cn_name"
placeholder="请输入中文姓名 (如: 张三)" placeholder="请输入中文姓名 (如: 张三)"
:disabled="isEdit" :disabled="isEdit || !userStore.hasPermission('system_user:operation')"
@input="handleNameInput" @input="handleNameInput"
/> />
</el-form-item> </el-form-item>
<el-form-item label="登录账号" prop="username"> <el-form-item label="登录账号" prop="username" v-if="hasFormFieldPermission('username')">
<el-input <el-input
v-model="form.username" v-model="form.username"
placeholder="自动生成,可修改 (如: zhangsan)" placeholder="自动生成,可修改 (如: zhangsan)"
:disabled="isEdit" :disabled="isEdit || !userStore.hasPermission('system_user:operation')"
> >
<template #append> <template #append>
<span v-if="!isEdit" style="font-size: 12px; color: #999;">重复自动+1</span> <span v-if="!isEdit" style="font-size: 12px; color: #999;">重复自动+1</span>
@ -78,16 +88,17 @@
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password" v-if="hasFormFieldPermission('password')">
<el-input <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
show-password show-password
:placeholder="isEdit ? '不修改请留空' : '设置初始密码'" :placeholder="isEdit ? '不修改请留空' : '设置初始密码'"
:disabled="!userStore.hasPermission('system_user:operation')"
/> />
</el-form-item> </el-form-item>
<el-form-item label="所属部门" prop="department"> <el-form-item label="所属部门" prop="department" v-if="hasFormFieldPermission('department')">
<el-select <el-select
v-model="form.department" v-model="form.department"
placeholder="请输入或选择部门" placeholder="请输入或选择部门"
@ -95,13 +106,14 @@
filterable filterable
allow-create allow-create
default-first-option default-first-option
:disabled="!userStore.hasPermission('system_user:operation')"
> >
<el-option v-for="item in departmentOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in departmentOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="系统角色" prop="role"> <el-form-item label="系统角色" prop="role" v-if="hasFormFieldPermission('role')">
<el-select v-model="form.role" placeholder="授予权限" style="width: 100%"> <el-select v-model="form.role" placeholder="授予权限" style="width: 100%" :disabled="!userStore.hasPermission('system_user:operation')">
<el-option <el-option
v-for="option in roleOptions" v-for="option in roleOptions"
:key="option.value" :key="option.value"
@ -111,15 +123,15 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="邮箱" prop="email"> <el-form-item label="邮箱" prop="email" v-if="hasFormFieldPermission('email')">
<el-input v-model="form.email" placeholder="请输入邮箱" /> <el-input v-model="form.email" placeholder="请输入邮箱" :disabled="!userStore.hasPermission('system_user:operation')" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button> <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 ? '确认修改' : '确认创建' }} {{ isEdit ? '确认修改' : '确认创建' }}
</el-button> </el-button>
</div> </div>
@ -136,6 +148,37 @@ import { ElMessage } from 'element-plus'
import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro
const userStore = useUserStore() 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 tableLoading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
@ -241,6 +284,7 @@ const getList = async () => {
tableData.value = res.data || [] tableData.value = res.data || []
extractDepartments(tableData.value) extractDepartments(tableData.value)
} catch (error) { } catch (error) {
// 错误已由全局拦截器统一处理
console.error('Fetch users failed:', error) console.error('Fetch users failed:', error)
} finally { } finally {
tableLoading.value = false tableLoading.value = false
@ -317,7 +361,7 @@ const onSubmit = async () => {
dialogVisible.value = false dialogVisible.value = false
getList() getList()
} catch (error) { } catch (error) {
// request 拦截器会处理错误 // 错误已由全局拦截器统一处理
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
@ -342,6 +386,7 @@ const handleDelete = async (row: any) => {
ElMessage.success('删除成功') ElMessage.success('删除成功')
getList() getList()
} catch (error) { } catch (error) {
// 错误已由全局拦截器统一处理
} }
} }

View File

@ -13,10 +13,14 @@
</template> </template>
<div class="scan-section"> <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> <el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span> <span class="text">点击开启全屏扫码</span>
</div> </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"> <div class="input-box">
<el-input <el-input
@ -26,12 +30,13 @@
clearable clearable
ref="barcodeRef" ref="barcodeRef"
size="large" size="large"
:disabled="!userStore.hasPermission('op_borrow:operation')"
> >
<template #prefix> <template #prefix>
<el-icon><Scissor /></el-icon> <el-icon><Scissor /></el-icon>
</template> </template>
<template #append> <template #append>
<el-button @click="handleManualInput">添加</el-button> <el-button @click="handleManualInput" :disabled="!userStore.hasPermission('op_borrow:operation')">添加</el-button>
</template> </template>
</el-input> </el-input>
</div> </div>
@ -40,16 +45,16 @@
<div class="cart-section"> <div class="cart-section">
<div v-if="cartItems.length > 0"> <div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%"> <el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column prop="name" label="物品名称" min-width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('name')" 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('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}"> <template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag> <el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
</template> </template>
</el-table-column> </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}"> <template #default="{row}">
<el-input-number <el-input-number
v-model="row.out_quantity" v-model="row.out_quantity"
@ -57,11 +62,12 @@
:max="parseFloat(row.available_quantity)" :max="parseFloat(row.available_quantity)"
size="small" size="small"
style="width: 100px" style="width: 100px"
:disabled="!userStore.hasPermission('op_borrow:operation')"
/> />
</template> </template>
</el-table-column> </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}"> <template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" /> <el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template> </template>
@ -102,7 +108,7 @@
</el-form-item> </el-form-item>
<el-form-item label="领用人签名确认" required> <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"> <div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" /> <img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span> <span class="re-sign-tip">点击重签</span>
@ -112,11 +118,17 @@
<span>点击此处进行全屏签名</span> <span>点击此处进行全屏签名</span>
</div> </div>
</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-item>
<div class="bottom-actions"> <div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button> <el-button v-if="userStore.hasPermission('op_borrow:operation')" @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')" type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出 确认借出
</el-button> </el-button>
</div> </div>
@ -187,6 +199,27 @@ import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode } from '@/api/outbound' import { getStockByBarcode } from '@/api/outbound'
import request from '@/utils/request' import request from '@/utils/request'
import { uploadFile } from '@/api/common/upload' 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('') const barcodeInput = ref('')

View File

@ -19,12 +19,12 @@
v-loading="loading" v-loading="loading"
:row-class-name="tableRowClassName" :row-class-name="tableRowClassName"
> >
<el-table-column prop="borrow_no" label="单号" width="180" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column prop="borrower_name" label="借用人" width="100" /> <el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('sku')" 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_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}"> <template #default="{row}">
<div v-if="row.status === 'returned'"> <div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag> <el-tag type="success" size="small">实际</el-tag>
@ -40,7 +40,7 @@
</template> </template>
</el-table-column> </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}"> <template #default="{row}">
<el-tag :type="row.status==='returned'?'success':'warning'"> <el-tag :type="row.status==='returned'?'success':'warning'">
{{ row.status==='returned'?'已还':'借出中' }} {{ row.status==='returned'?'已还':'借出中' }}
@ -48,22 +48,22 @@
</template> </template>
</el-table-column> </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}"> <template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span> <span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span> <span v-else style="color:#ccc">-</span>
</template> </template>
</el-table-column> </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}"> <template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px"> <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> <template #reference><el-tag size="small"></el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" /> <img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover> </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> <template #reference><el-tag type="success" size="small"></el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" /> <img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover> </el-popover>
@ -88,6 +88,32 @@ import request from '@/utils/request'
import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date
import 'dayjs/locale/zh-cn' // 导入中文包 import 'dayjs/locale/zh-cn' // 导入中文包
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 list = ref<any[]>([])
const total = ref(0) const total = ref(0)

View File

@ -13,10 +13,14 @@
</template> </template>
<div class="scan-section"> <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> <el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span> <span class="text">点击开启全屏扫码</span>
</div> </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"> <div class="input-box">
<el-input <el-input
@ -26,12 +30,13 @@
clearable clearable
ref="barcodeRef" ref="barcodeRef"
size="large" size="large"
:disabled="!userStore.hasPermission('op_return:operation')"
> >
<template #prefix> <template #prefix>
<el-icon><Scissor /></el-icon> <el-icon><Scissor /></el-icon>
</template> </template>
<template #append> <template #append>
<el-button @click="scanItem">识别</el-button> <el-button @click="scanItem" :disabled="!userStore.hasPermission('op_return:operation')">识别</el-button>
</template> </template>
</el-input> </el-input>
</div> </div>
@ -40,16 +45,17 @@
<div class="cart-section"> <div class="cart-section">
<div v-if="returnList.length > 0"> <div v-if="returnList.length > 0">
<el-table :data="returnList" border stripe style="width: 100%"> <el-table :data="returnList" border stripe style="width: 100%">
<el-table-column prop="borrower_name" label="借用人" width="90" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('borrower_name')" 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('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}"> <template #default="{row}">
<el-input <el-input
v-model="row.return_location" v-model="row.return_location"
:placeholder="`原: ${row.current_location || '无'}`" :placeholder="`原: ${row.current_location || '无'}`"
clearable clearable
size="small" size="small"
:disabled="!userStore.hasPermission('op_return:operation')"
> >
<template #append v-if="row.return_location !== row.current_location"> <template #append v-if="row.return_location !== row.current_location">
<span style="color: #E6A23C; font-size: 12px;">变更</span> <span style="color: #E6A23C; font-size: 12px;">变更</span>
@ -58,7 +64,7 @@
</template> </template>
</el-table-column> </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}"> <template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" /> <el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" />
</template> </template>
@ -77,7 +83,7 @@
<el-form label-position="top"> <el-form label-position="top">
<el-form-item required> <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"> <div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" /> <img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span> <span class="re-sign-tip">点击重签</span>
@ -87,12 +93,18 @@
<span>点击此处进行库管签名</span> <span>点击此处进行库管签名</span>
</div> </div>
</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-item>
</el-form> </el-form>
<div class="bottom-actions"> <div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button> <el-button v-if="userStore.hasPermission('op_return:operation')" @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')" type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select">
确认归还 确认归还
</el-button> </el-button>
</div> </div>
@ -161,6 +173,25 @@ import { uploadFile } from '@/api/common/upload'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue' import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.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('') const barcode = ref('')