44 Commits

Author SHA1 Message Date
dxc
8ba1ff37f0 V3.49 2026-06-16 14:54:03 +08:00
DXC
1450e6c1de fix(借还记录列表): 按 borrow_no 单号维度分页 + 修 SQLAlchemy Row 适配错误
- 分页基准从明细行改为单号:21 项单号不再被拆到 3 页

- 步骤 1a 构造 GROUP BY borrow_no 的 subquery(sort_key + status 聚合)

- 步骤 2 主查询 SELECT order_subq.c.borrow_no 一列,避免触发 PG GROUP BY 严格模式 (f405)

- 步骤 3 用 page_borrow_nos 拉明细,保留前端 groupMap 期望的 items 形态

- pagination.items 用 isinstance + hasattr(_mapping) 兜底提取纯字符串(修 psycopg2 can't adapt type 'Row')

- service 加 try-except,路由层识别 500 透传 traceback

- status 过滤改为单号聚合(borrowed=至少一条未还,returned=全部归还)
2026-06-16 14:53:42 +08:00
dxc
b9c25ff4c5 V3.47 2026-06-16 13:57:23 +08:00
DXC
b79b0f99af fix(借库扫码出库): 撤销 joinedload 修复 PG "FOR UPDATE cannot be applied to nullable side of outer join"
- 83b3db6 引入的 joinedload(ModelClass.base) 触发 LEFT OUTER JOIN,
  而 with_for_update() 会被 SQLAlchemy 透传到 join 的 nullable 侧,
  PG 直接抛 FeatureNotSupported,且连表加锁有死锁风险
- 退回最安全的单表 FOR UPDATE 模式,接受 N+1 lazy 加载的代价
- 在 防线3 上方加防回归注释,明确禁止未来再加 joinedload
- process_return 中的另两处 joinedload 不带 FOR UPDATE,不受 PG 限制,保留
2026-06-16 13:56:11 +08:00
DXC
83b3db693a fix(借库扫码出库): 校验 key 从 (source_table, sku) 改为 (name, spec_model) + N+1 修复
- 借库申请按 (name, spec_model) 发起,审批明细无 sku 字段;
  旧代码用 sku 做 key 会导致所有条目坍塌到同一桶,校验形同虚设
- 改为在扫码循环内即时累加、即时拦截:
  防线4 锁定 stock 行后从 material_base 取真实 (name, spec_model),
  与审批单按 strip 后的 (name, spec_model) 聚合比对
- 新增 joinedload(ModelClass.base) 一次 JOIN 加载 base,
  避免循环内 stock.base 触发 N+1
- 修正 dispatch_borrow docstring 中"sku 用于超额交叉校验"的错误描述
2026-06-16 13:50:49 +08:00
DXC
bfeb397c4a fix(借库扫码出库): items 字段名 stock_id → id 修复 400 + dispatch_borrow docstring 补全 sku 字段说明 2026-06-16 13:38:51 +08:00
dxc
dcef91c3b1 V3.47 2026-06-12 15:05:06 +08:00
DXC
5c0c1632c3 fix(审批邮件): items_json序列化Bug修复 + 邮件方法出库/借库物理隔离 2026-06-12 15:04:57 +08:00
DXC
6f5652b90e fix(借库菜单): 调整路由顺序 + 统一菜单命名 2026-06-12 14:21:03 +08:00
DXC
7ef22a3830 feat(借库审批流): 完整前后端实现 2026-06-12 14:08:19 +08:00
DXC
941bd20fbd fix(借库审批): borrow_service pytz时区修复 + transactions except块traceback增强 2026-06-12 14:06:17 +08:00
DXC
7ee6b0e02f 借库申请页面:恢复按 BOM 套餐添加功能 2026-06-12 13:19:01 +08:00
dxc
9e83c31f39 V3.46 2026-06-12 11:10:58 +08:00
DXC
6ad00884ba 借库列表:主子表聚合 + 展开行内嵌明细 2026-06-12 11:07:23 +08:00
DXC
9a5e3ee6b0 TransService.get_records: 追加 material_name 字段 + SKU 兜底查询解决数据孤岛问题 2026-06-12 11:06:34 +08:00
dxc
67bc5b6c5d V3.45 2026-06-11 17:36:34 +08:00
dxc
6cf5a25d77 V3.44 2026-06-10 12:16:00 +08:00
DXC
6686747e57 BomManage: saveDraftData 简化为全局唯一草稿静默覆盖模式 2026-06-10 12:11:53 +08:00
dxc
74bc751624 V3.43 2026-06-10 11:36:06 +08:00
DXC
c7b84ff3c6 fix: BOM草稿模块缺陷修复(事务回滚 + 外键约束 + 前端状态清理) 2026-06-10 11:30:07 +08:00
dxc
0e6d294052 V3.42 2026-06-05 16:28:43 +08:00
DXC
93b9846fc6 feat: 以图搜图集成拍照功能,支持直接调起摄像头搜图 2026-06-05 15:47:50 +08:00
DXC
1def8c7747 fix: 修复物料管理菜单空白,修正子菜单显示名称 2026-06-05 15:41:05 +08:00
DXC
907c083107 feat: 新增 Odoo 风格物料管理视图及相关路由,优化成品入库逻辑 2026-06-05 15:35:43 +08:00
DXC
afe0f25415 物料类别隔离校验:buy 改黑名单 + semi/product 改精确路径匹配,消除子串包含Bug 2026-06-05 13:01:39 +08:00
DXC
ffc482bd9e BomManage: 增删改刷新保留折叠状态,搜索时才重置 activeCategories 2026-06-05 11:31:37 +08:00
DXC
7087769a33 BomManage: 首屏懒渲染优化 — el-table 加 v-if 按需挂载,v-loading 替换为骨架屏 2026-06-05 11:24:40 +08:00
DXC
3d30cbc5c2 BomManage: autocomplete 添加 validate-event=false,rules 校验字段从 parent_id 改为 parentNameInput 2026-06-05 11:05:41 +08:00
DXC
355a21e94c 物料搜索:el-select 重构为 el-autocomplete Regression 修复(value-key 缺失 + parentNameInput 未声明 + onChildClear 不完整) 2026-06-05 11:02:35 +08:00
DXC
ff5418afa3 入库模块:物料搜索点击无感修复 + 类别校验白名单准入制
前端(buy/semi/product/service.vue,4 文件):

修复物料搜索"点击已聚焦 input 时内容被清空"交互 bug。

el-select 在 filterable+remote 模式下点击已聚焦的 input 时,el-select 内部

会 emit query='' 触发 remote-method,绕过 handleMaterialDropdownVisible

入口保护,直接清空 searchKeyword 和 materialOptions,导致用户被迫重写。

新增两层防御实现"编辑无感":

1) handleMaterialDropdownVisible 入口拦截:已选过物料(form.base_id 有值)

   时下拉打开直接 return,不请求默认列表

2) handleSearchMaterial 内部拦截:拦截 el-select 内部 emit 的空 query,

   仅在 form.base_id 有值 + safeQuery 为空 + 列表非空时 return

后端(buy/semi/product_service.py,3 文件):

入库类别校验从黑名单改为白名单准入制,彻底杜绝"成品进半成品库"

等非法组合(d94b52b 黑名单方案"成品不能进采购库"已挡不住这种组合)。

- buy_service.py: 黑名单(禁半成品/成品进采购)→ 白名单(必须含"原材料")

- semi_service.py: 统一错误信息格式为"只有【半成品】才允许半成品入库!"

- product_service.py: 统一错误信息格式为"只有【成品】才允许成品入库!"

- 三处空 category 统一显示为"未分类"

配合前端已修复的 catch 块(e.response.data.msg 精准提取),后端新错误

信息可原样弹窗给用户。
2026-06-04 17:57:17 +08:00
DXC
d94b52bf73 入库模块:物料类别隔离硬性校验(写拦截,读放宽) 2026-06-04 17:19:43 +08:00
DXC
8bb3e58b44 前端全局:<el-select remote> 三道防线扩展到 BOM 配方/采购/采购入库/售后入库
- 第一道防线:<el-select> 模板显式补充 reserve-keyword="true" / default-first-option="true",覆盖 4 文件 5 实例

- 第二道防线:handleRemoteSearch / handleSearchMaterial 首行深度净化 query(零宽字符/控制字符/BOM/不可见 Unicode)

- 第三道防线:handleVisibleChange / handleMaterialDropdownVisible 加竞态守卫,已有 searchKeyword 或 options 非空时跳过默认列表加载;带 debounce 的场景主动 clearTimeout 互斥

- service.vue 原本缺少 searchKeyword 状态,本轮新增 ref('') 专供 el-select 守卫使用

- BomManage.vue 父件/子件共用 handleVisibleChange,两套守卫分别按 parentQueryParams.keyword 和 state.queryParams.keyword 隔离判断
2026-06-04 16:44:59 +08:00
DXC
cdac915a4b 半成品/成品入库:物料/BOM 远程搜索粘贴失效 Bug 修复(三层防御)
- 深度净化 query:剔除零宽字符(U+200B-U+200D)/BOM(U+FEFF)/控制字符(U+0000-U+001F,U+007F-U+009F),应对外部复制粘贴混入隐形 Unicode 导致 ilike 匹配失败的场景

- 显式 reserve-keyword="true" / default-first-option="true":物料与 BOM 两个 <el-select> 全部显式标注,防止 Element 框架在选择后清空关键字(BOM 下拉框原缺失)

- handleMaterialDropdownVisible 竞态守卫:粘贴时 remote-method 与 @visible-change 同时触发,后者会 clearTimeout 前者的 debounce 定时器并加载默认列表覆盖结果。新增 !searchKeyword 守卫 + 主动 clearTimeout 互斥
2026-06-04 16:34:36 +08:00
DXC
8a2da1ac1e 半成品/成品入库:BOM 编号下拉按父件规格联动过滤(前后端双端改造)
- 后端 /inbound/{semi,product}/search-bom 增加 parent_spec 可选参数,Service 层在 MaterialBase.spec_model 上加等值过滤
2026-06-04 16:01:48 +08:00
DXC
332ae3c4cf 基础信息页:产品图/说明书上传后预览不显示修复 + 新增 Ctrl+V 粘贴蓝字提示
- customUpload 改为手动 push:移除 onSuccess(res) 调用,规避 el-upload 2.13.1 handleSuccess 未从 res.data.url 提取 url 的问题
2026-06-04 15:43:38 +08:00
DXC
d51c6f147f 前端:所有 <el-dialog> 统一添加 :close-on-click-modal="false" 防误触关闭(保留 Esc 关闭) 2026-06-04 15:16:16 +08:00
DXC
2977acbae7 BOM 配方管理:禁止编辑原数据,引入另存为(深拷贝+清 ID)+ 只读查看模式(点击编号进只读弹窗) 2026-06-04 14:44:29 +08:00
DXC
90eed24441 基础信息页:编辑弹窗新增另存为新项功能(清主键+切标题+清脏检查基准,复用 addMaterialBase 接口) 2026-06-04 14:07:34 +08:00
DXC
91444034e0 基础信息页:将出厂名称展示文案统一改为专业名称(5 处,变量名/接口字段保持不变) 2026-06-04 13:32:52 +08:00
DXC
8f901e3f08 基础信息页:类别→规格型号自动提取正则扩展为支持字母+数字(如 Opt9) 2026-06-04 13:27:00 +08:00
DXC
bac670ef7a 基础信息页:计量单位改 el-select(下拉历史+手动输入);表单排版重排为 4 行(类别占满行);类别末级英文后缀自动填规格型号 2026-06-04 13:22:51 +08:00
DXC
1c0c02fd36 基础信息页新增/编辑弹窗隐藏“可见等级”表单项(v-if=“false”,代码保留可恢复) 2026-06-04 11:40:34 +08:00
DXC
fffee9d964 入库管理三页面类别搜索中间节点支持子级匹配(buy/semi/product 类别过滤改为 ilike 前缀,与基础信息页一致) 2026-06-04 11:31:44 +08:00
DXC
a3d47f6328 入库管理三页面类别搜索统一为级联选择器;基础信息“俗名”改名为“出厂名称” 2026-06-04 11:05:58 +08:00
40 changed files with 6203 additions and 1153 deletions

View File

@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git *)",
"Bash(del *)",
"Bash(rm *)"
]
},
"$version": 3
}

View File

@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)"
]
}
}

View File

@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from sqlalchemy import or_ from sqlalchemy import or_
from app.services.bom_service import BomService, _cache_delete from app.services.bom_service import BomService, _cache_delete
from app.services.bom_draft_service import BomDraftService
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
@ -420,3 +421,61 @@ def get_cascade_inventory():
except Exception as e: except Exception as e:
current_app.logger.error(f'级联库存计算失败: {str(e)}') current_app.logger.error(f'级联库存计算失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== BOM 草稿接口 ====================
@bom_bp.route('/draft/save', methods=['POST'])
@jwt_required()
def save_draft():
"""暂存草稿"""
data = request.get_json()
bom_no = data.get('bom_no')
version = data.get('version', 'V1.0')
parent_id = data.get('parent_id')
children = data.get('children', [])
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
if not parent_id:
return jsonify({'code': 400, 'msg': 'parent_id 不能为空'}), 400
bom_draft_no = BomDraftService.save_draft(bom_no, version, parent_id, children)
return jsonify({'code': 200, 'msg': '草稿暂存成功', 'data': {'bom_no': bom_draft_no}})
@bom_bp.route('/draft/detail', methods=['GET'])
@jwt_required()
def get_draft_detail():
"""读取草稿详情"""
bom_no = request.args.get('bom_no')
version = request.args.get('version', 'V1.0')
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
draft = BomDraftService.get_draft_detail(bom_no, version)
# 【核心修改】:查不到草稿是正常现象,返回 HTTP 200 即可
if draft is None:
return jsonify({'code': 200, 'msg': '无草稿', 'data': None}), 200
return jsonify({'code': 200, 'msg': '查询成功', 'data': draft})
@bom_bp.route('/draft/publish', methods=['POST'])
@jwt_required()
def publish_draft():
"""发布草稿为正式 BOM"""
data = request.get_json()
bom_no = data.get('bom_no')
version = data.get('version', 'V1.0')
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
try:
bom_draft_no = BomDraftService.publish_draft(bom_no, version)
return jsonify({'code': 200, 'msg': 'BOM 发布成功', 'data': {'bom_no': bom_draft_no}})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400

View File

@ -98,6 +98,24 @@ def search_base():
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 1.1 计量单位字典接口 (GET /api/v1/inbound/base/units)
# ==============================================================================
@inbound_base_bp.route('/units', methods=['GET'])
@permission_required('material_list')
def get_unit_dict():
"""
获取所有已存在的非空计量单位(去重 + 排序),用于前端
新增/编辑弹窗中"计量单位"下拉框的历史记录。
"""
try:
units = MaterialBaseService.get_distinct_units()
return jsonify({"code": 200, "msg": "success", "data": units})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ============================================================================== # ==============================================================================
# 2. 列表接口 (GET /api/v1/inbound/base/list) # 2. 列表接口 (GET /api/v1/inbound/base/list)
# ============================================================================== # ==============================================================================

View File

@ -60,7 +60,8 @@ def search_base():
def search_bom(): def search_bom():
try: try:
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = ProductInboundService.search_bom_options(keyword) parent_spec = request.args.get('parent_spec', None)
data = ProductInboundService.search_bom_options(keyword, parent_spec=parent_spec)
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()

View File

@ -60,7 +60,8 @@ def search_base():
def search_bom(): def search_bom():
try: try:
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = SemiInboundService.search_bom_options(keyword) parent_spec = request.args.get('parent_spec', None)
data = SemiInboundService.search_bom_options(keyword, parent_spec=parent_spec)
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()

View File

@ -3,6 +3,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, audit_log from app.utils.decorators import permission_required, audit_log
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.trans_service import TransService from app.services.trans_service import TransService
from app.services.borrow_service import BorrowApprovalService
import traceback import traceback
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions') trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
@ -29,6 +30,16 @@ def get_current_user_permissions():
return perms return perms
def get_current_user_info():
"""获取当前用户信息和角色"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None, None
user = SysUser.query.get(identity)
return user.id if user else None, user.role if user else None
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'): def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
""" """
根据用户权限过滤 item 字典,无权限的字段值置为 None 根据用户权限过滤 item 字典,无权限的字段值置为 None
@ -120,8 +131,201 @@ def get_records():
search_type = request.args.get('search_type', 'all') search_type = request.args.get('search_type', 'all')
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword, search_type=search_type) res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword, search_type=search_type)
# ★ service 层异常时code==500 的字典(带 traceback需要直通到前端便于排查
if isinstance(res, dict) and res.get('code') == 500:
return jsonify({
'code': 500,
'msg': res.get('msg', '服务内部错误'),
'trace': res.get('trace', '')
}), 500
# 字段级脱敏 # 字段级脱敏
user_permissions = get_current_user_permissions() user_permissions = get_current_user_permissions()
if res.get('items'): if res.get('items'):
res['items'] = [filter_item_by_permissions(item, user_permissions, 'op_records') for item in res['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})
# ==============================================================================
# 借库审批流 API与出库审批流平行
# ==============================================================================
# --- 提交借库申请 ---
@trans_bp.route('/borrow/request', methods=['POST'])
@jwt_required()
def submit_borrow_request():
"""
提交借库申请(仅存储意向,不扣库存)
请求体: { items: [...], allowed_approvers: [...], remark: '', approver_id: int }
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
from app.models.system import SysUser
current_user = SysUser.query.get(user_id)
current_username = current_user.username if current_user else None
data = request.get_json() or {}
items = data.get('items', [])
if not items:
return jsonify({'code': 400, 'msg': '借库物品列表不能为空'}), 400
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
if missing:
return jsonify({
'code': 400,
'msg': f'{idx + 1}条物品缺少必填字段: {", ".join(missing)}'
}), 400
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的借库数量必须大于0'}), 400
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': f'{idx + 1}条物品的 quantity 格式无效'}), 400
approver_id = data.get('approver_id')
_default_approvers = [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
]
allowed_approvers = data.get('allowed_approvers') or _default_approvers
approval = BorrowApprovalService.submit_approval(
applicant_id=user_id,
items=items,
allowed_approvers=allowed_approvers,
remark=data.get('remark'),
approver_id=approver_id,
borrower_name=current_username
)
return jsonify({'code': 200, 'msg': '借库申请已提交', 'data': approval.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
return jsonify({'code': 500, 'msg': f"接口内部报错: {str(e)}", 'trace': traceback.format_exc()}), 500
# --- 审批借库申请 ---
@trans_bp.route('/borrow/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
def approve_borrow_request(request_id):
"""
审批借库申请
请求体: {"action": "approve" | "reject", "reject_reason": "驳回原因"}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作,仅支持 approve 或 reject'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
success, message, approval = BorrowApprovalService.approve(
request_id=request_id,
user_id=user_id,
user_role=user_role,
action=action,
reject_reason=reject_reason
)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({'code': 200, 'msg': message, 'data': approval.to_dict() if approval else None}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --- 获取借库审批单列表 ---
@trans_bp.route('/borrow/request', methods=['GET'])
@jwt_required()
def get_borrow_request_list():
"""
获取借库审批单列表
Query参数: page, limit, applicant_id, status
"""
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
applicant_id = request.args.get('applicant_id')
if applicant_id:
applicant_id = int(applicant_id)
status = request.args.get('status')
if status is not None:
status = int(status)
result = BorrowApprovalService.get_request_list(
page=page, per_page=limit, applicant_id=applicant_id, status=status
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
except Exception as e:
return jsonify({'code': 500, 'msg': str(e)}), 500
# --- 执行借库扣减(审批通过后调用)---
@trans_bp.route('/borrow/dispatch', methods=['POST'])
@jwt_required()
@permission_required('op_borrow:operation')
def dispatch_borrow():
"""
执行借库扣减
请求体: {
approval_id: int, // 关联的审批单ID
items: [ // 扫码选中的库存物品
{
id: int, // 库存主键(按 source_table 路由到 StockBuy/StockSemi/StockProduct
source_table: str, // 'stock_buy' | 'stock_semi' | 'stock_product'
sku: str, // 可选;不参与审批上限校验
out_quantity: float
}
],
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
borrower_name: str,
signature_path: str,
remark: str,
expected_return_time: str
}
"""
try:
data = request.get_json() or {}
approval_id = data.get('approval_id')
if not approval_id:
return jsonify({'code': 400, 'msg': '缺少 approval_id'}), 400
borrow_no = TransService.execute_dispatch(
approval_id=approval_id,
items=data.get('items', []),
operator_name=get_jwt_identity(),
borrower_name=data.get('borrower_name'),
signature=data.get('signature_path'),
remark=data.get('remark'),
expected_return_time=data.get('expected_return_time')
)
return jsonify({'code': 200, 'msg': '借库成功', 'data': {'borrow_no': borrow_no}}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500

View File

@ -5,8 +5,18 @@ class BomTable(db.Model):
__tablename__ = 'bom_table' __tablename__ = 'bom_table'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 父子件关联高频列 parent_id = db.Column(
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 子件过滤高频列 db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=False,
index=True
)
child_id = db.Column(
db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=False,
index=True
)
bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号') # ★ Redis 缓存 Key + 列表查询核心列 bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号') # ★ Redis 缓存 Key + 列表查询核心列
version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') # ★ 配合 bom_no 做唯一性约束 version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') # ★ 配合 bom_no 做唯一性约束
@ -24,5 +34,15 @@ class BomTable(db.Model):
) )
# relationships # relationships
parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents') parent = db.relationship(
child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children') 'MaterialBase',
foreign_keys=[parent_id],
backref='bom_parents',
passive_deletes=True
)
child = db.relationship(
'MaterialBase',
foreign_keys=[child_id],
backref='bom_children',
passive_deletes=True
)

View File

@ -0,0 +1,38 @@
from app.extensions import db
class BomDraftTable(db.Model):
__tablename__ = 'bom_draft_table'
id = db.Column(db.Integer, primary_key=True)
bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号')
version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本')
parent_id = db.Column(
db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=True,
comment='父件物料ID'
)
child_id = db.Column(
db.Integer,
db.ForeignKey('material_base.id', ondelete='SET NULL'),
nullable=True,
comment='子件物料ID'
)
dosage = db.Column(db.Numeric(19, 4), comment='个数')
loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%')
remark = db.Column(db.Text, comment='备注')
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now(), comment='更新时间')
parent = db.relationship(
'MaterialBase',
foreign_keys=[parent_id],
backref='bom_draft_parents',
passive_deletes=True
)
child = db.relationship(
'MaterialBase',
foreign_keys=[child_id],
backref='bom_draft_children',
passive_deletes=True
)

View File

@ -0,0 +1,96 @@
from app.extensions import db, beijing_time
from app.models.system import SysUser
from datetime import datetime
import json
class BorrowApproval(db.Model):
"""
借库审批单模型
用于管理借库申请的多级审批流程
"""
__tablename__ = 'borrow_approval'
id = db.Column(db.Integer, primary_key=True)
# 审批单号
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
# 申请人ID
applicant_id = db.Column(db.Integer, nullable=False, index=True)
# 申请说明
remark = db.Column(db.Text)
# 状态: 0-待审批, 1-已通过, 2-已驳回, 3-已完成(已借出)
status = db.Column(db.Integer, default=0, nullable=False)
# 允许审批的人员列表 (JSON格式)
allowed_approvers = db.Column(db.Text)
# 实际审批人ID
actual_approver_id = db.Column(db.Integer, index=True)
# 审批时间
approved_at = db.Column(db.DateTime)
# 驳回原因
reject_reason = db.Column(db.Text)
# 借库人姓名(申请时填写,审批通过后流转至 TransBorrow
borrower_name = db.Column(db.String(100))
# 明细快照 (存储借库物品的名称、规格、库位、数量等信息)
items_json = db.Column(db.Text, nullable=False)
# 创建时间和更新时间
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
def _safe_parse_json(self, value):
if value is None:
return []
if isinstance(value, (list, dict)):
return value
if isinstance(value, str):
val = value.strip()
if not val:
return []
try:
parsed = json.loads(val)
return parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError, ValueError):
return []
return []
def get_items(self):
return self._safe_parse_json(self.items_json)
def set_items(self, items):
self.items_json = json.dumps(items, ensure_ascii=False) if items else '[]'
def get_allowed_approvers(self):
return self._safe_parse_json(self.allowed_approvers)
def set_allowed_approvers(self, approvers):
self.allowed_approvers = json.dumps(approvers, ensure_ascii=False) if approvers else '[]'
def to_dict(self):
return {
'id': self.id,
'request_no': self.request_no,
'applicant_id': self.applicant_id,
'applicant_name': self._get_user_name(self.applicant_id),
'remark': self.remark,
'status': self.status,
'status_text': ['待审批', '已通过', '已驳回', '已完成'][self.status] if self.status in [0, 1, 2, 3] else '未知',
'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id,
'approver_name': self._get_user_name(self.actual_approver_id) if self.actual_approver_id else None,
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
'reject_reason': self.reject_reason,
'borrower_name': self.borrower_name,
'items': self.get_items(),
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
}
def _get_user_name(self, user_id):
if not user_id:
return ""
try:
user = SysUser.query.get(user_id)
return user.username if user else f"未知用户({user_id})"
except Exception:
return f"用户({user_id})"

View File

@ -0,0 +1,145 @@
from app.extensions import db
from app.models.bom_draft import BomDraftTable
from app.models.base import MaterialBase
from app.services.bom_service import BomService
import logging
logger = logging.getLogger(__name__)
class BomDraftService:
@staticmethod
def save_draft(bom_no, version, parent_id, children):
try:
# 1. 删除旧草稿
old = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old:
db.session.delete(rec)
db.session.flush()
# 2. 如果没有任何子件,必须插入一条只包含 parent_id 的占位头数据
if not children:
dummy_draft = BomDraftTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=None, dosage=0, loss_rate=0, remark=''
)
db.session.add(dummy_draft)
else:
# 正常批量插入新草稿行
for child in children:
draft = BomDraftTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=child.get('child_id'),
dosage=child.get('dosage', 0),
loss_rate=child.get('loss_rate', 0),
remark=child.get('remark', '')
)
db.session.add(draft)
db.session.commit()
except Exception as e:
db.session.rollback()
logger.error(f"[BomDraft] save_draft 失败 bom_no={bom_no}: {e}")
raise
return bom_no
@staticmethod
def get_draft_detail(bom_no, version):
rows = db.session.query(
BomDraftTable,
MaterialBase.name.label('child_name'),
MaterialBase.spec_model.label('child_spec')
).outerjoin(
MaterialBase, BomDraftTable.child_id == MaterialBase.id
).filter(
BomDraftTable.bom_no == bom_no,
BomDraftTable.version == version
).all()
if not rows:
return None
first = rows[0].BomDraftTable
parent_id = first.parent_id
parent_material = MaterialBase.query.get(parent_id) if parent_id else None
children = []
for draft, child_name, child_spec in rows:
# 过滤掉保存 BOM 头时插入的占位空行
if draft.child_id is not None:
children.append({
'child_id': draft.child_id,
'child_name': child_name or '',
'child_spec': child_spec or '',
'dosage': float(draft.dosage) if draft.dosage else 0.0,
'loss_rate': float(draft.loss_rate) if draft.loss_rate else 0.0,
'remark': draft.remark or '',
})
return {
'bom_no': bom_no,
'version': first.version,
'parent_id': parent_id,
'parent_name': parent_material.name if parent_material else '',
'parent_spec': parent_material.spec_model if parent_material else '',
'children': children,
}
@staticmethod
def publish_draft(bom_no, version):
"""
发布草稿为正式 BOM
1. 获取草稿数据
2. 强校验(父件不为空、子件列表非空、所有子件 ID>0、用量>0
3. 调用 BomService.save_bom 写入正式 bom_table
4. 清空草稿数据
"""
try:
# 步骤 1
draft = BomDraftService.get_draft_detail(bom_no, version)
if not draft:
raise ValueError('草稿不存在')
# 步骤 2强校验
if not draft.get('parent_id'):
raise ValueError('发布失败:父件不能为空')
children = draft.get('children', [])
if not children:
raise ValueError('发布失败:子件列表不能为空')
for child in children:
if not child.get('child_id') or child['child_id'] <= 0:
raise ValueError('发布失败子件ID必须大于0')
dosage = child.get('dosage')
if not dosage or dosage <= 0:
raise ValueError('发布失败子件用量必须大于0')
# 步骤 3复用正式 BOM 的写入逻辑(跨版本查重 + 缓存清理均在 save_bom 内完成)
publish_data = {
'bom_no': bom_no,
'version': version,
'parent_id': draft['parent_id'],
'children': [
{
'child_id': child['child_id'],
'dosage': child['dosage'],
'remark': child.get('remark', ''),
}
for child in children
],
}
BomService.save_bom(publish_data)
# 步骤 4清空草稿数据
old_rows = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old_rows:
db.session.delete(rec)
db.session.commit()
logger.info(f"[BomDraft] publish_draft bom_no={bom_no} version={version} -> 已发布并清空草稿")
except Exception as e:
db.session.rollback()
logger.error(f"[BomDraft] publish_draft 失败 bom_no={bom_no}: {e}")
raise
return bom_no

View File

@ -431,6 +431,7 @@ class BomService:
@staticmethod @staticmethod
def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'): def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'):
try:
if not bom_no: if not bom_no:
existing = BomTable.query.filter_by(parent_id=parent_id).first() existing = BomTable.query.filter_by(parent_id=parent_id).first()
bom_no = existing.bom_no if existing else BomService.generate_bom_no() bom_no = existing.bom_no if existing else BomService.generate_bom_no()
@ -451,7 +452,10 @@ class BomService:
# ===== 写入后立刻清除缓存Cache Invalidation ===== # ===== 写入后立刻清除缓存Cache Invalidation =====
_cache_delete(bom_no, version) _cache_delete(bom_no, version)
logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}") logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}")
except Exception as e:
db.session.rollback()
logger.error(f"[BOM] create_or_update_bom 失败 bom_no={bom_no}: {e}")
raise
return True return True
@staticmethod @staticmethod

View File

@ -0,0 +1,401 @@
from datetime import datetime
import pytz
from app.extensions import db
from app.models.borrow import BorrowApproval
from app.models.system import SysUser
class BorrowApprovalService:
"""借库审批服务"""
@staticmethod
def generate_request_no():
"""
生成审批单号: APR-BOR-yyyyMMdd-HHmm-当日流水(4位)
"""
beijing_tz = pytz.timezone('Asia/Shanghai')
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"APR-BOR-{date_str}-"
latest = db.session.query(BorrowApproval.request_no).filter(
BorrowApproval.request_no.like(f"{prefix}%")
).order_by(BorrowApproval.id.desc()).first()
if latest:
last_seq = int(latest[0].split('-')[-1])
sequence = last_seq + 1
else:
sequence = 1
return f"APR-BOR-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None,
borrower_name=None):
"""
提交借库申请(仅存储意向,不扣库存)
Args:
applicant_id: 申请人ID
items: 借库物品明细列表,每个物品应包含:
- name: 物料名称 (必填)
- spec_model: 规格型号 (必填)
- quantity: 计划借库数量 (必填)
- warehouse_location: 库位 (可选)
- remark: 物品备注 (可选)
allowed_approvers: 允许审批的人员/角色列表
approver_id: 指定审批人ID可选
remark: 申请说明
borrower_name: 借库人姓名(必填)
Returns:
BorrowApproval 实例
Raises:
ValueError: 当 items 为空或缺少必填字段时抛出
"""
if not items:
raise ValueError("借库物品列表不能为空")
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing_fields = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
if missing_fields:
raise ValueError(
f"{idx + 1} 条物品缺少必填字段: {', '.join(missing_fields)}"
f"必须包含: name, spec_model, quantity"
)
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
raise ValueError(f"{idx + 1} 条物品的借库数量必须大于0")
except (TypeError, ValueError) as e:
raise ValueError(f"{idx + 1} 条物品的 quantity 格式无效: {str(e)}")
if not allowed_approvers:
raise ValueError("必须指定至少一位审批人")
if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
request_no = BorrowApprovalService.generate_request_no()
approval = BorrowApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
borrower_name=borrower_name,
status=0, # 待审批
)
approval.set_items(items)
approval.set_allowed_approvers(allowed_approvers)
db.session.add(approval)
db.session.commit()
# ★ 创建成功后,发送邮件通知审批人(静默处理,不阻断主流程)
BorrowApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
return approval
@staticmethod
def _get_emails_by_identifiers(applicant_id=None, role_codes=None):
"""
根据用户ID或角色列表查询邮箱地址
Args:
applicant_id: 用户ID (按 SysUser.id 查找)
role_codes: 角色代码列表,如 ['SUPERVISOR', 'SUPER_ADMIN']
Returns:
去重后的邮箱地址列表
"""
emails = []
if applicant_id:
user = SysUser.query.get(int(applicant_id))
if user and user.email:
emails.append(user.email)
if role_codes:
for code in role_codes:
users = SysUser.query.filter_by(role=code).all()
for u in users:
if u.email:
emails.append(u.email)
return list(set(emails))
@staticmethod
def _notify_new_request(approval, applicant_id, approver_id=None):
"""发送新借库申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
try:
from flask import current_app
from app.utils.email_service import send_borrow_new_request_notify
from app.models.system import SysUser
applicant_name = ''
applicant_emails = []
# 1. 收集申请人信息
if applicant_id:
user = SysUser.query.get(int(applicant_id))
if user and user.email:
applicant_emails.append(user.email)
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or str(applicant_id))
# 2. 收集审批人信息
approver_emails = []
if approver_id:
user = SysUser.query.get(int(approver_id))
if user and user.email:
approver_emails.append(user.email)
else:
# 兜底:按角色查询
approvers = approval.get_allowed_approvers()
role_codes = []
for a in approvers:
if a.get('type') == 'role':
role_codes.append(a.get('value', ''))
approver_emails = BorrowApprovalService._get_emails_by_identifiers(role_codes=role_codes)
# 去重
all_emails = list(set(applicant_emails + approver_emails))
if not all_emails:
current_app.logger.info(f"[Email] 借库审批单 {approval.request_no} 无收件人邮箱,跳过通知")
return
# 3. 获取物料明细
items = approval.get_items()
# 4. 分别发送邮件
if applicant_emails:
try:
send_borrow_new_request_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=f"您的借库申请已提交,等待审批。{approval.remark or ''}",
items=items,
is_applicant_notify=True
)
except Exception as e:
current_app.logger.error(f"[Email] 通知申请人失败: {e}")
if approver_emails:
try:
send_borrow_new_request_notify(
to_emails=approver_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=approval.remark or '',
items=items,
is_applicant_notify=False
)
except Exception as e:
current_app.logger.error(f"[Email] 通知审批人失败: {e}")
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 发送新借库申请通知邮件失败: {e}")
except RuntimeError:
import traceback
traceback.print_exc()
@staticmethod
def _notify_approval_result(approval, approver_id, action):
"""发送借库审批结果通知邮件(静默处理,不阻断主流程)"""
import logging
logger = logging.getLogger(__name__)
try:
from app.utils.email_service import send_borrow_approval_result_notify, send_borrow_dispatch_notify
from app.models.system import SysUser as SU
# 1. 提取申请人信息
applicant_name = ''
applicant_emails = []
if approval.applicant_id:
user = SU.query.get(approval.applicant_id)
if user:
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '')
if user.email:
applicant_emails.append(user.email)
# 2. 提取物料明细
items = approval.get_items() if approval else []
# 3. 分支逻辑
if action == 'approve':
# 3.1 通知申请人(审批已通过,明确告知结果)
if applicant_emails:
try:
send_borrow_approval_result_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
is_passed=True,
reject_reason='',
applicant_name=applicant_name
)
except Exception as e:
logger.error(f"[Email] 通知申请人(通过)失败: {e}")
else:
logger.warning("[Email] 申请人无邮箱,无法发送审批通过通知")
# 3.2 通知库管(请备货)
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
warehouse_emails = BorrowApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
if warehouse_emails:
try:
send_borrow_dispatch_notify(
to_emails=warehouse_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
items=items
)
except Exception as e:
logger.error(f"[Email] 通知库管失败: {e}")
else:
logger.warning("[Email] 无库管角色邮箱,无法发送备货通知")
elif action == 'reject':
# 3.3 通知申请人(已驳回)
if applicant_emails:
try:
send_borrow_approval_result_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
is_passed=False,
reject_reason=approval.reject_reason or '未说明原因',
applicant_name=applicant_name
)
except Exception as e:
logger.error(f"[Email] 通知申请人驳回失败: {e}")
else:
logger.warning("[Email] 申请人无邮箱,无法发送驳回通知")
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"[Email] 外层发送异常: {e}")
@staticmethod
def can_approve(approval, user_id, user_role):
"""
检查用户是否有权限审批
"""
approvers = approval.get_allowed_approvers()
if user_role and user_role.upper() == 'SUPER_ADMIN':
return True
for approver in approvers:
approver_type = approver.get('type', '')
approver_value = approver.get('value', '')
if approver_type == 'user' and str(approver_value) == str(user_id):
return True
if approver_type == 'role' and approver_value == user_role:
return True
return False
@staticmethod
def approve(request_id, user_id, user_role, action='approve', reject_reason=None):
"""
执行审批操作
Returns:
(success: bool, message: str, approval: BorrowApproval or None)
"""
beijing_tz = pytz.timezone('Asia/Shanghai')
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
approval = BorrowApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 0:
return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None
if not BorrowApprovalService.can_approve(approval, user_id, user_role):
return False, "您没有审批此单的权限", None
try:
if action == 'approve':
approval.status = 1 # 已通过
approval.actual_approver_id = user_id
approval.approved_at = current_time
elif action == 'reject':
approval.status = 2 # 已驳回
approval.reject_reason = reject_reason
else:
return False, "无效的审批操作", None
db.session.commit()
# ★ 审批后,发送邮件通知(静默处理,不阻断主流程)
BorrowApprovalService._notify_approval_result(approval, user_id, action)
return True, "审批成功", approval
except Exception as e:
db.session.rollback()
return False, f"审批失败: {str(e)}", None
@staticmethod
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
"""
获取审批单列表
"""
from sqlalchemy import desc
query = BorrowApproval.query
if applicant_id:
query = query.filter(BorrowApproval.applicant_id == applicant_id)
if status is not None:
query = query.filter(BorrowApproval.status == status)
query = query.order_by(desc(BorrowApproval.created_at))
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [item.to_dict() for item in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_request_by_id(request_id):
"""根据ID获取审批单"""
return BorrowApproval.query.get(request_id)
@staticmethod
def mark_completed(request_id):
"""标记审批单为已完成(借库执行完成后调用)"""
approval = BorrowApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 1:
return False, f"只有已通过的审批单才能标记为完成 (当前状态: {approval.status})", None
try:
approval.status = 3 # 已完成
db.session.commit()
return True, "审批单已完成", approval
except Exception as e:
db.session.rollback()
return False, f"操作失败: {str(e)}", None

View File

@ -528,6 +528,29 @@ class MaterialBaseService:
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": [], "companies": []} return {"categories": [], "types": [], "companies": []}
@staticmethod
def get_distinct_units():
"""
获取所有已存在且非空的计量单位(去重 + 排序)。
用于前端"基础信息"新增/编辑弹窗的"计量单位"下拉历史记录。
SQL 语义:
SELECT DISTINCT unit FROM material_base
WHERE unit IS NOT NULL AND unit != ''
ORDER BY unit ASC
"""
try:
rows = db.session.query(MaterialBase.unit) \
.filter(MaterialBase.unit.isnot(None), MaterialBase.unit != '') \
.distinct() \
.all()
sorted_units = sorted([u[0] for u in rows if u[0]])
return sorted_units
except Exception as e:
traceback.print_exc()
print(f"查询计量单位字典失败: {e}")
return []
@staticmethod @staticmethod
def create_material(data): def create_material(data):
"""新增基础信息""" """新增基础信息"""

View File

@ -100,6 +100,12 @@ class BuyInboundService:
if not material: raise ValueError("所选物料不存在") if not material: raise ValueError("所选物料不存在")
if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用") if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用")
# ============================================================
# 物料类别隔离校验:采购入库禁止【半成品】和【成品】(黑名单拦截制)
# ============================================================
if material.category and ('/半成品' in material.category or '/成品' in material.category):
raise ValueError(f"物料【{material.name}】属于【{material.category}】,【半成品】和【成品】不允许直接采购入库!")
# ============================================================ # ============================================================
# 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告 # 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告
# ============================================================ # ============================================================
@ -382,7 +388,8 @@ class BuyInboundService:
# 2. 类别独立搜索 # 2. 类别独立搜索
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
# 3. 类型独立搜索 # 3. 类型独立搜索
if material_type and material_type.strip(): if material_type and material_type.strip():

View File

@ -66,7 +66,7 @@ class ProductInboundService:
return {"items": [], "total": 0, "page": 1, "has_next": False} return {"items": [], "total": 0, "page": 1, "has_next": False}
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword, parent_spec=None):
from app.models.bom import BomTable from app.models.bom import BomTable
try: try:
query = db.session.query( query = db.session.query(
@ -79,6 +79,9 @@ class ProductInboundService:
if hasattr(BomTable, 'is_enabled'): if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True) query = query.filter(BomTable.is_enabled == True)
if parent_spec:
query = query.filter(MaterialBase.spec_model == parent_spec)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
query = query.filter( query = query.filter(
@ -112,6 +115,12 @@ class ProductInboundService:
if not material.is_enabled: if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。") raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# ============================================================
# 物料类别隔离校验:成品入库必须为【成品】类目(精确白名单准入制)
# ============================================================
if not material.category or '/成品' not in material.category:
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【成品】才允许进行成品入库!")
ProductInboundService._check_unique( ProductInboundService._check_unique(
serial_number=data.get('serial_number') serial_number=data.get('serial_number')
) )
@ -349,7 +358,8 @@ class ProductInboundService:
sku_str = f'%{sku.strip()}%' sku_str = f'%{sku.strip()}%'
query = query.filter(StockProduct.sku.ilike(sku_str)) query = query.filter(StockProduct.sku.ilike(sku_str))
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
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())

View File

@ -71,7 +71,7 @@ class SemiInboundService:
return {"items": [], "total": 0, "page": 1, "has_next": False} return {"items": [], "total": 0, "page": 1, "has_next": False}
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword, parent_spec=None):
from app.models.bom import BomTable from app.models.bom import BomTable
try: try:
query = db.session.query( query = db.session.query(
@ -84,6 +84,9 @@ class SemiInboundService:
if hasattr(BomTable, 'is_enabled'): if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True) query = query.filter(BomTable.is_enabled == True)
if parent_spec:
query = query.filter(MaterialBase.spec_model == parent_spec)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
query = query.filter( query = query.filter(
@ -119,6 +122,12 @@ class SemiInboundService:
if not material.is_enabled: if not material.is_enabled:
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。") raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
# ============================================================
# 物料类别隔离校验:半成品入库必须为【半成品】类目(精确白名单准入制)
# ============================================================
if not material.category or '/半成品' not in material.category:
raise ValueError(f"物料【{material.name}】属于【{material.category or '未分类'}】,只有【半成品】才允许进行半成品入库!")
SemiInboundService._check_unique( SemiInboundService._check_unique(
base_id=base_id, base_id=base_id,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
@ -439,7 +448,8 @@ class SemiInboundService:
sku_str = f'%{sku.strip()}%' sku_str = f'%{sku.strip()}%'
query = query.filter(StockSemi.sku.ilike(sku_str)) query = query.filter(StockSemi.sku.ilike(sku_str))
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
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())

View File

@ -709,7 +709,7 @@ class OutboundApprovalService:
"""发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)""" """发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
try: try:
from flask import current_app from flask import current_app
from app.utils.email_service import send_new_request_notify from app.utils.email_service import send_outbound_new_request_notify
from app.models.system import SysUser from app.models.system import SysUser
applicant_name = '' applicant_name = ''
@ -749,7 +749,7 @@ class OutboundApprovalService:
# 4. 分别发送邮件 # 4. 分别发送邮件
if applicant_emails: if applicant_emails:
try: try:
send_new_request_notify( send_outbound_new_request_notify(
to_emails=applicant_emails, to_emails=applicant_emails,
request_no=approval.request_no, request_no=approval.request_no,
applicant_name=applicant_name, applicant_name=applicant_name,
@ -762,7 +762,7 @@ class OutboundApprovalService:
if approver_emails: if approver_emails:
try: try:
send_new_request_notify( send_outbound_new_request_notify(
to_emails=approver_emails, to_emails=approver_emails,
request_no=approval.request_no, request_no=approval.request_no,
applicant_name=applicant_name, applicant_name=applicant_name,
@ -871,7 +871,7 @@ class OutboundApprovalService:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
from app.utils.email_service import send_approval_result_notify, send_warehouse_dispatch_notify from app.utils.email_service import send_outbound_approval_result_notify, send_outbound_dispatch_notify
from app.models.system import SysUser as SU from app.models.system import SysUser as SU
# 1. 提取申请人信息(供两个分支使用) # 1. 提取申请人信息(供两个分支使用)
@ -885,7 +885,7 @@ class OutboundApprovalService:
applicant_emails.append(user.email) applicant_emails.append(user.email)
# 2. 提取物料明细(供通过分支使用) # 2. 提取物料明细(供通过分支使用)
items = approval.items_json if approval.items_json else [] items = approval.get_items() if approval else []
# 3. 分支逻辑 # 3. 分支逻辑
if action == 'approve': if action == 'approve':
@ -895,7 +895,7 @@ class OutboundApprovalService:
if warehouse_emails: if warehouse_emails:
try: try:
send_warehouse_dispatch_notify( send_outbound_dispatch_notify(
to_emails=warehouse_emails, to_emails=warehouse_emails,
request_no=approval.request_no, request_no=approval.request_no,
applicant_name=applicant_name, applicant_name=applicant_name,
@ -907,7 +907,7 @@ class OutboundApprovalService:
# 3.2 通知申请人(审批通过,带完整物料清单) # 3.2 通知申请人(审批通过,带完整物料清单)
if applicant_emails: if applicant_emails:
try: try:
send_warehouse_dispatch_notify( send_outbound_dispatch_notify(
to_emails=applicant_emails, to_emails=applicant_emails,
request_no=approval.request_no, request_no=approval.request_no,
applicant_name=applicant_name, applicant_name=applicant_name,
@ -920,7 +920,7 @@ class OutboundApprovalService:
# 3.3 通知申请人(已驳回) # 3.3 通知申请人(已驳回)
if applicant_emails: if applicant_emails:
try: try:
send_approval_result_notify( send_outbound_approval_result_notify(
to_emails=applicant_emails, to_emails=applicant_emails,
request_no=approval.request_no, request_no=approval.request_no,
is_passed=False, is_passed=False,

View File

@ -6,7 +6,8 @@ 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.base import MaterialBase from app.models.base import MaterialBase
from sqlalchemy import desc, func, nullslast, asc, or_, and_ from sqlalchemy import desc, func, nullslast, asc, or_, and_, case
from sqlalchemy.orm import joinedload
class TransService: class TransService:
@ -29,18 +30,57 @@ class TransService:
return f"{prefix}{sequence:04d}" return f"{prefix}{sequence:04d}"
@staticmethod @staticmethod
def create_borrow(data, operator_name='System'): def execute_dispatch(approval_id, items, operator_name='System', borrower_name=None,
signature=None, remark=None, expected_return_time=None):
""" """
借库逻辑:减少可用库存,不减总库存 执行借库扣减(审批通过后调用)
流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
★ 关键设计:审批维度是 (name, spec_model) 而非 SKU
借库申请是按【名称 + 规格型号】发起的borrow_service 强制要求 name/spec_model/quantity 三字段),
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
""" """
items = data.get('items', []) from app.models.borrow import BorrowApproval
borrower_name = data.get('borrower_name')
signature = data.get('signature_path') # 借用人签字
if not items: raise ValueError("物品列表为空") if not items: raise ValueError("物品列表为空")
if not borrower_name: raise ValueError("请输入借用人")
if not signature: raise ValueError("借用人必须签字") if not signature: raise ValueError("借用人必须签字")
# ==============================================
# ★ 防线1并发防重复执行 - 用 SELECT FOR UPDATE 锁住审批单
# ==============================================
approval = BorrowApproval.query.with_for_update().get(approval_id)
if not approval:
raise ValueError("审批单不存在")
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库")
# ★ borrower_name 兜底:优先用前端传参,其次从审批单读取(申请时填写的姓名)
if not borrower_name:
borrower_name = approval.borrower_name
if not borrower_name:
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
# ==============================================
# ★ 防线2构建审批上限字典按 名称+规格 聚合strip 防止匹配失败)
# Key = (name, spec_model)Value = 该物料累计允许借出数量
# ==============================================
approved_items = approval.get_items()
if not approved_items:
raise ValueError("审批单中无物料明细,请联系管理员检查")
approval_limits = {}
for ai in approved_items:
key = (
(ai.get('name') or '').strip(),
(ai.get('spec_model') or '').strip()
)
approval_limits[key] = approval_limits.get(key, 0) + float(ai.get('quantity', 0))
# 累计本次扫码出库量key 与 approval_limits 完全一致)
dispatch_acc = {}
borrow_no = TransService.generate_borrow_no() borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
@ -53,16 +93,50 @@ class TransService:
ModelClass = model_map.get(source_table) ModelClass = model_map.get(source_table)
if not ModelClass: continue if not ModelClass: continue
# ==============================================
# ★ 防线3并发超卖与负库存 - 锁行后再查可用库存
# ⚠️ 不要在此加 joinedload(ModelClass.base)PG 禁止 FOR UPDATE
# 应用到 outer join 的 nullable 侧,会报 FeatureNotSupported
# 并有死锁风险。stock.base 走单条 lazy 加载是已知取舍。
# ==============================================
stock = ModelClass.query.with_for_update().get(stock_id) stock = ModelClass.query.with_for_update().get(stock_id)
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}") if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
# ==============================================
# ★ 防线4名称+规格 超额校验(动态累加、即时拦截)
# 库存表本身没有 name/spec_model 字段,通过 base 关联到 material_base
# ==============================================
if stock.base:
stock_name = (stock.base.name or '').strip()
stock_spec = (stock.base.spec_model or '').strip()
else:
stock_name = ''
stock_spec = ''
key = (stock_name, stock_spec)
limit = approval_limits.get(key)
if limit is None:
raise ValueError(
f"扫码物料【{stock_name} / {stock_spec}】不在审批单允许范围内,"
f"请检查审批单明细或重新发起申请"
)
dispatch_acc[key] = dispatch_acc.get(key, 0) + qty
current_total = dispatch_acc[key]
if current_total > limit:
raise ValueError(
f"实际出库数量超出了审批单允许的上限: "
f"物料={stock_name}({stock_spec}) "
f"审批上限={limit}, 实际扫码={current_total}"
)
if float(stock.available_quantity) < qty: if float(stock.available_quantity) < qty:
raise ValueError(f"SKU {stock.sku} 可用库存不足") raise ValueError(f"物料【{stock_name} / {stock_spec}可用库存不足")
# 1. 冻结库存 (只减可用) # 1. 冻结库存 (只减可用)
stock.available_quantity = float(stock.available_quantity) - qty stock.available_quantity = float(stock.available_quantity) - qty
# 2. 创建借用 # 2. 创建借用记录
record = TransBorrow( record = TransBorrow(
borrow_no=borrow_no, borrow_no=borrow_no,
sku=stock.sku, sku=stock.sku,
@ -72,19 +146,39 @@ class TransService:
quantity=qty, quantity=qty,
borrower_name=borrower_name, borrower_name=borrower_name,
borrow_signature=signature, borrow_signature=signature,
remark=data.get('remark'), remark=remark,
expected_return_time=data.get('expected_return_time'), expected_return_time=expected_return_time,
status='borrowed', status='borrowed',
is_returned=False is_returned=False
) )
db.session.add(record) db.session.add(record)
# ★ 3. 标记审批单为已完成
approval.status = 3
db.session.commit() db.session.commit()
return borrow_no return borrow_no
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
raise e raise e
# ★ 兼容旧入口(不走审批流的直接借库,保留以便平滑过渡)
@staticmethod
def create_borrow(data, operator_name='System'):
"""
借库逻辑(兼容旧模式):减少可用库存,不减总库存
@deprecated 请优先使用 execute_dispatch 走审批流
"""
return TransService.execute_dispatch(
approval_id=0,
items=data.get('items', []),
operator_name=operator_name,
borrower_name=data.get('borrower_name'),
signature=data.get('signature_path'),
remark=data.get('remark'),
expected_return_time=data.get('expected_return_time')
)
@staticmethod @staticmethod
def scan_for_return(barcode): def scan_for_return(barcode):
""" """
@ -231,9 +325,51 @@ class TransService:
@staticmethod @staticmethod
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'): def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'):
q = TransBorrow.query """
获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize
# 如果有关键词,需要联表搜索物料名称和规格型号 实现思路(三步走):
步骤 1: 构造 GROUP BY borrow_no 的"单号维度视图" subquery
(包含 borrow_no + sort_key + 状态聚合,全部聚合都在这里完成)
步骤 2: 用一个【纯净的列查询】从 subquery 中分页得到 page_borrow_nos
→ SELECT 只有 borrow_no 一列,【主查询无 GROUP BY】
→ 避免触发 PG "column must appear in GROUP BY" 严格模式
步骤 3: 用 page_borrow_nos 拉明细 + 预加载 material_name
状态过滤按"单号聚合"判定:
- borrowed: 单号下至少有一条 is_returned=False
- returned: 单号下所有明细 is_returned=True
"""
try:
# ====================================================================
# 步骤 1a构造"单号维度"基础子查询GROUP BY borrow_no 在这里完成)
# ====================================================================
# 单号 + 排序键(最早 expected_return_time—— 这一层只含 2 列 + GROUP BY
order_subq = (
db.session.query(
TransBorrow.borrow_no.label('borrow_no'),
func.min(TransBorrow.expected_return_time).label('sort_key')
)
.group_by(TransBorrow.borrow_no)
.subquery()
)
# 状态聚合子查询(也是 GROUP BY borrow_no
status_subq = (
db.session.query(
TransBorrow.borrow_no.label('borrow_no'),
func.sum(
case((TransBorrow.is_returned == False, 1), else_=0)
).label('unreturned_count')
)
.group_by(TransBorrow.borrow_no)
.subquery()
)
# ====================================================================
# 步骤 1b构造关键词命中单号子查询保留原全部 search_type 逻辑)
# ====================================================================
keyword_conditions = None
if keyword: if keyword:
# 根据 search_type 构建不同的搜索条件 # 根据 search_type 构建不同的搜索条件
if search_type == 'all': if search_type == 'all':
@ -377,28 +513,155 @@ class TransService:
keyword_conditions = TransBorrow.id.in_(all_matches) keyword_conditions = TransBorrow.id.in_(all_matches)
else: # 把"命中的单号"独立成 subquery供主查询做 IN 过滤
keyword_conditions = None keyword_borrow_nos_subq = None
else:
keyword_conditions = None
if keyword_conditions is not None: if keyword_conditions is not None:
q = q.filter(keyword_conditions) keyword_borrow_nos_subq = (
db.session.query(TransBorrow.borrow_no)
.filter(keyword_conditions)
.distinct()
.subquery()
)
# ====================================================================
# 步骤 2纯净列查询分页SELECT 只有 order_subq.c.borrow_no 一列)
# ====================================================================
borrow_no_q = db.session.query(order_subq.c.borrow_no)
# 关键词过滤
if keyword_borrow_nos_subq is not None:
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(keyword_borrow_nos_subq)
)
# 状态过滤(按"单号聚合"判定)
if status == 'borrowed': if status == 'borrowed':
q = q.filter(TransBorrow.is_returned == False) # 单号下至少一条未还
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(
db.session.query(status_subq.c.borrow_no)
.filter(status_subq.c.unreturned_count > 0)
)
)
elif status == 'returned': elif status == 'returned':
q = q.filter(TransBorrow.is_returned == True) # 单号下所有明细都已归还
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(
db.session.query(status_subq.c.borrow_no)
.filter(status_subq.c.unreturned_count == 0)
)
)
# 使用 distinct 防止跨表查询产生重复记录 # 排序(单号维度的 sort_key ASC
q = q.distinct() borrow_no_q = borrow_no_q.order_by(nullslast(asc(order_subq.c.sort_key)))
q = q.order_by(nullslast(asc(TransBorrow.expected_return_time))) # 分页(基准 = borrow_no 单号数)
pagination = q.paginate(page=page, per_page=limit, error_out=False) pagination = borrow_no_q.paginate(page=page, per_page=limit, error_out=False)
# ★ pagination.items 是 SQLAlchemy Row 对象psycopg2 无法直接 adapt Row
# 用 isinstance(row, tuple) 不够2.x 的 Row 不一定继承 tuple
# 用 hasattr(row, '_mapping') 兜底,强制提取 row[0] 拿到纯字符串
page_borrow_nos = [
row[0] if isinstance(row, tuple) or hasattr(row, '_mapping') else row
for row in pagination.items
]
total_orders = pagination.total # ★ 单号总数(修复前是明细数,分页错乱根因)
if not page_borrow_nos:
return { return {
'items': [r.to_dict() for r in pagination.items], 'items': [],
'total': pagination.total, 'total': total_orders,
'page': page,
'limit': limit
}
# ====================================================================
# 步骤 3按当前页 borrow_no 集合一次性拉出所有明细
# ====================================================================
detail_records = (
TransBorrow.query
.filter(TransBorrow.borrow_no.in_(page_borrow_nos))
.order_by(TransBorrow.borrow_no.asc(), TransBorrow.id.asc())
.all()
)
# ============================================================
# ★ 批量预加载物料名称三步收集ID → 批量JOIN → SKU兜底
# ============================================================
items_with_names = []
items = detail_records
if items:
# 步骤 1收集所有 (source_table, stock_id) 对
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
for item in items:
if item.source_table in stock_ids_by_table and item.stock_id:
stock_ids_by_table[item.source_table].add(item.stock_id)
# 步骤 2批量查询库存表并 JOIN MaterialBase
stock_map = {} # { ('stock_buy', 101): '物料名称', ... }
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
for table_name, ids in stock_ids_by_table.items():
if not ids:
continue
ModelClass = model_map.get(table_name)
if not ModelClass:
continue
stocks = ModelClass.query.options(
joinedload(ModelClass.base)
).filter(ModelClass.id.in_(ids)).all()
for stock in stocks:
name = stock.base.name if stock.base else ''
stock_map[(table_name, stock.id)] = name
# 步骤 3前置收集 SKU 兜底候选集
empty_sku_set = set()
for item in items:
name = stock_map.get((item.source_table, item.stock_id), '')
if not name and item.sku:
empty_sku_set.add(item.sku)
# 步骤 3前置SKU 兜底批量查询
# 场景库存记录被跨表转移删旧建新trans_borrow.stock_id 指向孤立记录
# 通过 sku 在三张库存表中查找任意匹配,再通过 base_id 获取 MaterialBase.name
sku_name_map = {}
if empty_sku_set:
for ModelClass in [StockProduct, StockSemi, StockBuy]:
stocks = ModelClass.query.options(
joinedload(ModelClass.base)
).filter(
ModelClass.sku.in_(empty_sku_set)
).all()
for stock in stocks:
if stock.sku not in sku_name_map and stock.base:
sku_name_map[stock.sku] = stock.base.name
# 步骤 3为每条记录注入 material_name含 SKU 兜底)
for item in items:
item_dict = item.to_dict()
material_name = stock_map.get((item.source_table, item.stock_id), '')
if not material_name and item.sku:
material_name = sku_name_map.get(item.sku, '')
item_dict['material_name'] = material_name
items_with_names.append(item_dict)
return {
'items': items_with_names,
'total': total_orders,
'page': page,
'limit': limit
}
except Exception as e:
# ★ 捕鼠器:把任何 SQL/运行时错误以 500 + traceback 返回,避免静默吞噬
import traceback
return {
'code': 500,
'msg': str(e),
'trace': traceback.format_exc(),
'items': [],
'total': 0,
'page': page, 'page': page,
'limit': limit 'limit': limit
} }

View File

@ -118,24 +118,13 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str):
logger.error(f"[Email] 发送邮件时发生未知异常: {e}") logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
def send_new_request_notify(to_emails: List[str], request_no: str, def send_outbound_new_request_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', remark: str = '', applicant_name: str = '', remark: str = '',
items: list = None, is_applicant_notify: bool = False): items: list = None, is_applicant_notify: bool = False):
""" """
通知审批人有新的出库申请单待审批(可附带物料清单) 通知审批人有新的出库申请单待审批(可附带物料清单)
或通知申请人其申请已提交is_applicant_notify=True 时) 或通知申请人其申请已提交is_applicant_notify=True 时)
Args:
to_emails: 审批人邮箱列表
request_no: 审批单号
applicant_name: 申请人姓名
remark: 申请备注
items: 物料明细列表(可选)
is_applicant_notify: True=通知申请人标题您的出库申请已提交False=通知审批人(标题:您有一笔新的出库审批待处理)
""" """
print(f"[DEBUG send_new_request_notify] 入参 items={items}, is_applicant_notify={is_applicant_notify}")
# 拼装物料表格
rows = [] rows = []
rows.append("名称 | 规格 | 计划数量") rows.append("名称 | 规格 | 计划数量")
rows.append("-" * 40) rows.append("-" * 40)
@ -194,21 +183,78 @@ https://172.16.0.198/outbound/approval
send_email(to_emails, subject, content) send_email(to_emails, subject, content)
def send_approval_result_notify(to_emails: List[str], request_no: str, def send_borrow_new_request_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', remark: str = '',
items: list = None, is_applicant_notify: bool = False):
"""
通知审批人有新的借库申请单待审批(可附带物料清单)
或通知申请人其申请已提交is_applicant_notify=True 时)
"""
rows = []
rows.append("名称 | 规格 | 计划数量")
rows.append("-" * 40)
if items:
for item in items:
name = item.get('name', '-') or '-'
spec = item.get('spec_model', '-') or '-'
qty = item.get('quantity', '-') or '-'
rows.append(f"{name} | {spec} | {qty}")
else:
rows.append("(无物料明细)")
if is_applicant_notify:
subject = f"【已提交】您的借库申请单 {request_no} 已提交"
content = f"""您好,
您的借库申请单 {request_no} 已成功提交,等待审批。
申请单号:{request_no}
申请人:{applicant_name or '未知'}
备注说明:{remark or ''}
物料清单如下:
{chr(10).join(rows)}
---
您可以点击下方链接查看申请状态:
https://172.16.0.198/operation/borrow_apply
---
此邮件由系统自动发送,请勿回复。
"""
else:
subject = f"【待审批】借库申请单 {request_no}"
content = f"""您好,
您有一笔新的借库审批申请待处理:
申请单号:{request_no}
申请人:{applicant_name or '未知'}
备注说明:{remark or ''}
物料清单如下:
{chr(10).join(rows)}
---
⚡ 快速通道:
请点击下方链接直接进入系统审批:
https://172.16.0.198/operation/borrow_approval
---
请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
def send_outbound_approval_result_notify(to_emails: List[str], request_no: str,
is_passed: bool, reject_reason: str = '', is_passed: bool, reject_reason: str = '',
applicant_name: str = ''): applicant_name: str = ''):
""" """
通知审批结果 通知出库审批结果
Args:
to_emails: 收件人邮箱列表
request_no: 审批单号
is_passed: 是否通过(通过时发给库管,驳回时发给申请人)
reject_reason: 驳回原因(仅 is_passed=False 时使用)
applicant_name: 申请人姓名(仅驳回通知时使用)
""" """
if is_passed: if is_passed:
# ★ 发给申请人:告知已通过,去领料
subject = f"【已通过】出库申请单 {request_no}" subject = f"【已通过】出库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"} content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
@ -219,7 +265,6 @@ def send_approval_result_notify(to_emails: List[str], request_no: str,
此邮件由系统自动发送,请勿回复。 此邮件由系统自动发送,请勿回复。
""" """
else: else:
# ★ 发给申请人:告知被驳回
subject = f"【已驳回】出库申请单 {request_no}" subject = f"【已驳回】出库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"} content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
@ -234,19 +279,43 @@ def send_approval_result_notify(to_emails: List[str], request_no: str,
send_email(to_emails, subject, content) send_email(to_emails, subject, content)
def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str, def send_borrow_approval_result_notify(to_emails: List[str], request_no: str,
is_passed: bool, reject_reason: str = '',
applicant_name: str = ''):
"""
通知借库审批结果
"""
if is_passed:
subject = f"【已通过】借库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
您的借库申请单 {request_no} 已审批通过,请前往仓库扫码借出。
请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
else:
subject = f"【已驳回】借库申请单 {request_no}"
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}
借库申请单 {request_no} 已被审批驳回。
驳回原因:{reject_reason or '未填写'}
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)
def send_outbound_dispatch_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', items: list = None): applicant_name: str = '', items: list = None):
""" """
通知库管备货出库(包含完整物料清单) 通知库管备货出库(包含完整物料清单)
Args:
to_emails: 库管邮箱列表
request_no: 审批单号
applicant_name: 申请人姓名
items: 物料明细列表,每个元素包含 name/spec_model/warehouse_location/quantity
""" """
print(f"[DEBUG send_warehouse_dispatch_notify] 入参 items={items}") print(f"[DEBUG send_outbound_dispatch_notify] 入参 items={items}")
print(f"[DEBUG send_warehouse_dispatch_notify] items 类型={type(items)}, 长度={len(items) if items else 0}")
rows = [] rows = []
rows.append("名称 | 规格 | 库位 | 计划数量") rows.append("名称 | 规格 | 库位 | 计划数量")
@ -275,4 +344,39 @@ def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str,
此邮件由系统自动发送,请勿回复。 此邮件由系统自动发送,请勿回复。
""" """
send_email(to_emails, subject, content) send_email(to_emails, subject, content)
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {to_emails}")
def send_borrow_dispatch_notify(to_emails: List[str], request_no: str,
applicant_name: str = '', items: list = None):
"""
通知库管备货借库(包含完整物料清单)
"""
print(f"[DEBUG send_borrow_dispatch_notify] 入参 items={items}")
rows = []
rows.append("名称 | 规格 | 库位 | 计划数量")
rows.append("-" * 50)
if items:
for item in items:
name = item.get('name', '-') or '-'
spec = item.get('spec_model', '-') or '-'
loc = item.get('warehouse_location', '-') or '-'
qty = item.get('quantity', '-') or '-'
rows.append(f"{name} | {spec} | {loc} | {qty}")
else:
rows.append("(无物料明细)")
subject = f"【待借库】借库申请单 {request_no} 已审批通过"
content = f"""您好,
借库申请单 {request_no} 已审批通过,请按以下清单准备备货:
{chr(10).join(rows)}
申请人:{applicant_name or '未知'}
请登录仓库管理系统执行"扫码借库"操作。
此邮件由系统自动发送,请勿回复。
"""
send_email(to_emails, subject, content)

View File

@ -239,7 +239,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>
当前版本:V3.41 当前版本:V3.49
</span> </span>
</footer> </footer>
@ -251,7 +251,7 @@ const handleLogout = () => {
v-model="profileDialogVisible" v-model="profileDialogVisible"
title="个人中心" title="个人中心"
width="480px" width="480px"
:close-on-click-modal="!passwordLoading" :close-on-click-modal="false"
destroy-on-close destroy-on-close
class="profile-dialog" class="profile-dialog"
> >
@ -331,7 +331,7 @@ const handleLogout = () => {
</el-dialog> </el-dialog>
<!-- 绑定/修改邮箱弹窗 --> <!-- 绑定/修改邮箱弹窗 -->
<el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" @close="resetEmailForm"> <el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" :close-on-click-modal="false" @close="resetEmailForm">
<el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px"> <el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px">
<el-form-item label="新邮箱" prop="email"> <el-form-item label="新邮箱" prop="email">
<el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" /> <el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" />

View File

@ -42,3 +42,22 @@ export function deleteBom(bomNo: string, version: string) {
method: 'delete' method: 'delete'
}) })
} }
// ==========================================
// BOM 草稿相关接口
// ==========================================
// 1. 暂存草稿
export function saveDraft(data: any) {
return request({ url: '/v1/bom/draft/save', method: 'post', data })
}
// 2. 读取草稿详情
export function getDraftDetail(params: { bom_no: string; version?: string }) {
return request({ url: '/v1/bom/draft/detail', method: 'get', params })
}
// 3. 发布草稿
export function publishDraft(data: { bom_no: string; version: string }) {
return request({ url: '/v1/bom/draft/publish', method: 'post', data })
}

View File

@ -43,11 +43,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
} }
// 搜索BOM // 搜索BOM
export function searchBom(keyword: string) { export function searchBom(keyword: string, parent_spec?: string) {
return request({ return request({
url: '/inbound/product/search-bom', url: '/inbound/product/search-bom',
method: 'get', method: 'get',
params: { keyword } params: { keyword, parent_spec }
}) })
} }

View File

@ -45,11 +45,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
} }
// 5.5 搜索BOM (新增) // 5.5 搜索BOM (新增)
export function searchBom(keyword: string) { export function searchBom(keyword: string, parent_spec?: string) {
return request({ return request({
url: '/inbound/semi/search-bom', url: '/inbound/semi/search-bom',
method: 'get', method: 'get',
params: { keyword } params: { keyword, parent_spec }
}) })
} }

View File

@ -87,3 +87,11 @@ export function markWarningOrdered(data: { baseId: number; isOrdered: boolean })
data data
}) })
} }
// 9. 获取计量单位字典 (新增/编辑弹窗下拉历史)
export function getMaterialUnitsAPI() {
return request({
url: '/inbound/base/units',
method: 'get'
})
}

View File

@ -0,0 +1,194 @@
import request from '@/utils/request'
// 购物车商品项接口
export interface CartItem {
id: number
sku: string
name: string
spec_model: string
source_table: string
stock_quantity: number
available_quantity: number
barcode: string
price: number // 单价
out_quantity: number // 本次出库数量
}
// 提交出库单的数据结构
export interface OutboundSubmitData {
items: Array<{
sku: string
source_table: string
stock_id: number
barcode: string
quantity: number
price: number
}>
outbound_type: string
consumer_name: string
operator_name: string
signature_path: string // 上传后返回的图片路径
remark?: string
}
export interface ScanResult {
id: number
sku: string
name: string
spec_model: string
source_table: string // 'stock_buy' | 'stock_product' ...
stock_quantity: number
available_quantity: number
batch_number?: string
warehouse_location?: string
barcode?: string
price?: number // 扫描返回的价格
}
/**
* 根据条码获取库存物品详情
* @param barcode 扫描到的条码
*/
export function getStockByBarcode(barcode: string) {
return request<any, ScanResult>({
url: '/v1/outbound/scan',
method: 'get',
params: { barcode }
})
}
/**
* 提交出库单 (批量)
*/
export function submitOutbound(data: OutboundSubmitData) {
return request({
url: '/v1/outbound',
method: 'post',
data
})
}
/**
* 获取出库记录列表
*/
export function getOutboundList(params: any) {
return request({
url: '/v1/outbound',
method: 'get',
params
})
}
/**
* 提交出库申请单(申请人 → 审批流)
*/
export function submitOutboundRequest(data: {
items: Array<{
material_type?: string
name: string
spec_model: string
warehouse_location?: string
quantity: number
}>
remark: string
}) {
return request({
url: '/v1/outbound/request',
method: 'post',
data
})
}
/**
* 获取出库审批申请单列表
* @param params 支持 status, page, limit
*/
export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) {
return request({
url: '/v1/outbound/request',
method: 'get',
params
})
}
/**
* 审批(通过 / 驳回)出库申请单
* @param id 审批单ID
* @param data action: 'approve' | 'reject'reject 时需传 reject_reason
*/
export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
return request({
url: `/v1/outbound/request/${id}/approve`,
method: 'patch',
data
})
}
// ==============================================================================
// 借库审批流 API
// ==============================================================================
/**
* 提交借库申请单(申请人 → 审批流)
*/
export function submitBorrowRequest(data: {
items: Array<{
name: string
spec_model: string
warehouse_location?: string
quantity: number
}>
remark?: string
allowed_approvers?: Array<{ type: string; value: string }>
approver_id?: number
}) {
return request({
url: '/v1/transactions/borrow/request',
method: 'post',
data
})
}
/**
* 获取借库审批申请单列表
* @param params 支持 status, page, limit
*/
export function getBorrowApprovalList(params: { status?: number | ''; page?: number; limit?: number }) {
return request({
url: '/v1/transactions/borrow/request',
method: 'get',
params
})
}
/**
* 审批(通过 / 驳回)借库申请单
* @param id 审批单ID
* @param data action: 'approve' | 'reject'reject 时需传 reject_reason
*/
export function approveBorrowRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
return request({
url: `/v1/transactions/borrow/request/${id}/approve`,
method: 'patch',
data
})
}
/**
* 执行借库扣减(审批通过后调用)
* @param data approval_id + 扫码选中的物品 + 借用人信息 + 签名
*/
export function dispatchBorrow(data: {
approval_id: number
items: Array<any>
borrower_name: string
signature_path: string
remark?: string
expected_return_time?: string | null
}) {
return request({
url: '/v1/transactions/borrow/dispatch',
method: 'post',
data
})
}

View File

@ -33,6 +33,17 @@
</div> </div>
</el-upload> </el-upload>
<!-- 拍照按钮 -->
<el-button
v-if="!previewUrl"
type="primary"
class="camera-btn"
@click="openCamera"
>
<el-icon><VideoCamera /></el-icon>
调起摄像头拍照
</el-button>
<div v-if="searching" class="loading-tip"> <div v-if="searching" class="loading-tip">
<el-icon class="is-loading"><Loading /></el-icon> <el-icon class="is-loading"><Loading /></el-icon>
<span>正在识别图片并检索...</span> <span>正在识别图片并检索...</span>
@ -94,15 +105,33 @@
<template #footer> <template #footer>
<el-button @click="handleClose">关闭</el-button> <el-button @click="handleClose">关闭</el-button>
</template> </template>
<!-- 拍照弹窗 -->
<el-dialog
v-model="cameraVisible"
title="拍照"
width="95%"
style="max-width: 480px; height: 80vh; padding: 0;"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@close="closeCamera"
>
<WebRtcCamera
@cancel="closeCamera"
@photo-submit="handleCameraSubmit"
/>
</el-dialog>
</el-dialog> </el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Camera, Loading, Picture, WarningFilled } from '@element-plus/icons-vue' import { Camera, Loading, Picture, WarningFilled, VideoCamera } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { imageSearch, type ImageSearchItem } from '@/api/common/upload' import { imageSearch, type ImageSearchItem } from '@/api/common/upload'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
const router = useRouter() const router = useRouter()
@ -126,6 +155,9 @@ const searching = ref(false)
const searched = ref(false) const searched = ref(false)
const results = ref<ImageSearchItem[]>([]) const results = ref<ImageSearchItem[]>([])
// 拍照相关
const cameraVisible = ref(false)
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
visible.value = val visible.value = val
if (!val) { if (!val) {
@ -137,6 +169,27 @@ watch(visible, (val) => {
emit('update:modelValue', val) emit('update:modelValue', val)
}) })
// 拍照相关方法
const openCamera = () => {
cameraVisible.value = true
}
const closeCamera = () => {
cameraVisible.value = false
}
const handleCameraSubmit = (file: File) => {
// 关闭拍照弹窗
closeCamera()
// 生成预览
currentFile.value = file
previewUrl.value = URL.createObjectURL(file)
// 立即触发搜图
doSearch(file)
}
const handleFileChange = (uploadFile: any) => { const handleFileChange = (uploadFile: any) => {
const file = uploadFile.raw const file = uploadFile.raw
if (!file) return if (!file) return
@ -179,6 +232,9 @@ const doSearch = async (file: File) => {
} }
const clearImage = () => { const clearImage = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = '' previewUrl.value = ''
currentFile.value = null currentFile.value = null
results.value = [] results.value = []
@ -188,7 +244,6 @@ const clearImage = () => {
const fullImageUrl = (path: string) => { const fullImageUrl = (path: string) => {
if (!path) return ''; if (!path) return '';
// 直接原样返回,完全信任后端传过来的 image_url
return path.startsWith('http') ? path : path; return path.startsWith('http') ? path : path;
} }
@ -219,6 +274,9 @@ const handleClose = () => {
} }
const resetState = () => { const resetState = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
}
previewUrl.value = '' previewUrl.value = ''
currentFile.value = null currentFile.value = null
searching.value = false searching.value = false
@ -234,6 +292,12 @@ const resetState = () => {
min-height: 380px; min-height: 380px;
} }
/* 拍照按钮 */
.camera-btn {
width: 100%;
margin-top: 8px;
}
/* ── 左侧上传区 ── */ /* ── 左侧上传区 ── */
.upload-section { .upload-section {
flex: 0 0 220px; flex: 0 0 220px;

View File

@ -39,17 +39,24 @@ const routes: Array<RouteRecordRaw> = [
] ]
}, },
// 3. 基础信息 // 3. 物料管理
{ {
path: '/material', path: '/material',
component: Layout, component: Layout,
redirect: '/material/index', redirect: '/material/index',
meta: { title: '物料管理', icon: 'Box' },
children: [ children: [
{ {
path: 'index', path: 'index',
name: 'MaterialBase', name: 'MaterialBase',
component: () => import('@/views/material/list.vue'), component: () => import('@/views/material/list.vue'),
meta: { title: '基础信息', icon: 'Box' } meta: { title: '基础信息', icon: 'Box' }
},
{
path: 'buyOdoo',
name: 'BuyOdoo',
component: () => import('@/views/material/buyOdoo.vue'),
meta: { title: '基础信息(Odoo)', icon: 'Grid' }
} }
] ]
}, },
@ -202,11 +209,17 @@ const routes: Array<RouteRecordRaw> = [
meta: { title: '借库管理', icon: 'Operation' }, meta: { title: '借库管理', icon: 'Operation' },
redirect: '/operation/borrow', redirect: '/operation/borrow',
children: [ children: [
{
path: 'borrow_apply',
name: 'BorrowApply',
component: () => import('@/views/borrow/apply/index.vue'),
meta: { title: '借库选单' }
},
{ {
path: 'borrow', path: 'borrow',
name: 'OpBorrow', name: 'OpBorrow',
component: () => import('@/views/transaction/borrow.vue'), component: () => import('@/views/transaction/borrow.vue'),
meta: { title: '借库' } meta: { title: '扫码借库' }
}, },
{ {
path: 'repair', path: 'repair',
@ -219,6 +232,16 @@ const routes: Array<RouteRecordRaw> = [
name: 'OpRecords', name: 'OpRecords',
component: () => import('@/views/transaction/records.vue'), component: () => import('@/views/transaction/records.vue'),
meta: { title: '借还记录' } meta: { title: '借还记录' }
},
{
path: 'borrow_approval',
name: 'BorrowApproval',
component: () => import('@/views/borrow/approval/index.vue'),
meta: {
title: '借库审批',
icon: 'Stamp',
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}
} }
] ]
}, },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,365 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="list"
border
stripe
style="margin-top: 16px;"
row-key="id"
:expand-row-keys="expandedRows"
@expand-change="handleExpandChange"
>
<!-- 展开行 -->
<el-table-column type="expand" width="60" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
物料明细 {{ row.items?.length || 0 }}
</p>
<el-table
v-if="row.items?.length"
:data="row.items"
border
size="small"
style="width: 100%;"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="计划数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #F56C6C; font-weight: bold;">{{ item.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无物料明细" :image-size="60" />
</div>
</template>
</el-table-column>
<el-table-column prop="request_no" label="申请单号" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
{{ row.request_no }}
</el-link>
</template>
</el-table-column>
<el-table-column label="申请人" width="140">
<template #default="{ row }">
{{ getApplicantName(row.applicant_id) }}
</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="物料种类" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.items?.length || 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="审批信息" width="180">
<template #default="{ row }">
<template v-if="row.status === 1">
<span style="color: #67C23A;">{{ getApproverName(row.actual_approver_id) }}</span>
<br />
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
</template>
<template v-else-if="row.status === 2">
<span style="color: #F56C6C;">已驳回</span>
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
</el-tooltip>
</template>
<template v-else-if="row.status === 3">
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button
v-if="userStore.hasPermission('borrow_approval:operation')"
type="success"
size="small"
:loading="row._approving"
@click="handleApprove(row)"
>
通过
</el-button>
<el-button
v-if="userStore.hasPermission('borrow_approval:operation')"
type="danger"
size="small"
:loading="row._approving"
@click="openRejectDialog(row)"
>
驳回
</el-button>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- 驳回原因 Dialog -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请填写驳回原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getBorrowApprovalList, approveBorrowRequest } from '@/api/transaction'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const expandedRows = ref<string[]>([])
// 驳回 Dialog
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
const rejectLoading = ref(false)
// 申请人 / 审批人名称缓存
const userNameCache = ref<Record<number, string>>({})
// --- 工具函数 ---
const statusText = (status: number) => {
const map: Record<number, string> = {
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
}
return map[status] ?? '-'
}
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
}
return map[status] ?? 'info'
}
const getApplicantName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
const getApproverName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
// --- 展开行 ---
const toggleExpand = (row: any) => {
const idx = expandedRows.value.indexOf(row.id)
if (idx > -1) {
expandedRows.value.splice(idx, 1)
} else {
expandedRows.value.push(row.id)
}
}
const handleExpandChange = () => {}
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
limit: pageSize.value
}
if (filterStatus.value !== '') {
params.status = filterStatus.value
}
const res: any = await getBorrowApprovalList(params)
const records = res.data?.items || []
records.forEach((r: any) => {
if (r.applicant_id && !userNameCache.value[r.applicant_id]) {
if (r.applicant_name) {
userNameCache.value[r.applicant_id] = r.applicant_name
}
}
if (r.actual_approver_id && !userNameCache.value[r.actual_approver_id]) {
if (r.approver_name) {
userNameCache.value[r.actual_approver_id] = r.approver_name
}
}
r._approving = false
})
list.value = records
total.value = res.data?.total || records.length || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载审批列表失败')
} finally {
loading.value = false
}
}
// --- 筛选 ---
const handleStatusChange = () => {
page.value = 1
expandedRows.value = []
fetchData()
}
// --- 分页 ---
const handlePageChange = (p: number) => {
page.value = p
fetchData()
}
const handleSizeChange = (s: number) => {
pageSize.value = s
page.value = 1
fetchData()
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要通过借库申请单 【${row.request_no}】 吗?`,
'审批确认',
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' }
)
} catch {
return
}
row._approving = true
try {
await approveBorrowRequest(row.id, { action: 'approve' })
ElMessage.success(`申请单 ${row.request_no} 已通过`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '审批操作失败')
} finally {
row._approving = false
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写驳回原因')
return
}
rejectLoading.value = true
try {
await approveBorrowRequest(currentRejectRow.value.id, {
action: 'reject',
reject_reason: reason
})
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '驳回操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.app-container {
padding: 20px;
}
.filter-container {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@
<el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery"> <el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery">
<el-option label="全部" value="all" /> <el-option label="全部" value="all" />
<el-option label="名称" value="name" /> <el-option label="名称" value="name" />
<el-option label="俗名" value="common_name" /> <el-option label="专业名称" value="common_name" />
<el-option label="规格" value="spec" /> <el-option label="规格" value="spec" />
</el-select> </el-select>
</template> </template>
@ -182,7 +182,7 @@
<el-checkbox v-if="hasColPermission('id')" v-model="columns.id.visible" label="ID" /> <el-checkbox v-if="hasColPermission('id')" v-model="columns.id.visible" label="ID" />
<el-checkbox v-if="hasColPermission('companyName')" v-model="columns.companyName.visible" label="所属公司" /> <el-checkbox v-if="hasColPermission('companyName')" v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-if="hasColPermission('name')" v-model="columns.name.visible" label="名称" /> <el-checkbox v-if="hasColPermission('name')" v-model="columns.name.visible" label="名称" />
<el-checkbox v-if="hasColPermission('commonName')" v-model="columns.commonName.visible" label="俗名" /> <el-checkbox v-if="hasColPermission('commonName')" v-model="columns.commonName.visible" label="专业名称" />
<el-checkbox v-if="hasColPermission('category')" v-model="columns.category.visible" label="类别" /> <el-checkbox v-if="hasColPermission('category')" v-model="columns.category.visible" label="类别" />
<el-checkbox v-if="hasColPermission('type')" v-model="columns.type.visible" label="类型" /> <el-checkbox v-if="hasColPermission('type')" v-model="columns.type.visible" label="类型" />
<el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" /> <el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" />
@ -222,7 +222,7 @@
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip sortable="custom" /> <el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip sortable="custom" />
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip sortable="custom"> <el-table-column v-if="columns.commonName.visible" prop="commonName" label="专业名称" min-width="140" show-overflow-tooltip sortable="custom">
<template #default="scope"> <template #default="scope">
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span> <span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
<span v-else style="color: #ccc;">-</span> <span v-else style="color: #ccc;">-</span>
@ -363,13 +363,23 @@
append-to-body append-to-body
destroy-on-close destroy-on-close
@close="cancel" @close="cancel"
:close-on-click-modal="!isUploading" :close-on-click-modal="false"
:close-on-press-escape="!isUploading" :close-on-press-escape="!isUploading"
:show-close="!isUploading" :show-close="!isUploading"
> >
<template #header> <template #header>
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;"> <div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span> <span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
<div style="display: flex; align-items: center; gap: 16px;">
<el-link
v-if="form.id"
type="primary"
:underline="false"
style="font-size: 14px;"
@click="handleSaveAs"
>
<el-icon style="margin-right: 4px"><DocumentCopy /></el-icon>另存为新项
</el-link>
<el-link <el-link
v-if="form.id" v-if="form.id"
type="success" type="success"
@ -380,6 +390,7 @@
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM <el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link> </el-link>
</div> </div>
</div>
</template> </template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
@ -390,7 +401,7 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="俗名" prop="commonName" v-if="hasFieldPermission('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>
@ -409,6 +420,20 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('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
@ -430,26 +455,6 @@
style="width: 50%;" style="width: 50%;"
/> />
</div> </div>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -457,13 +462,26 @@
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')"> <el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')">
<el-input v-model="form.unit" placeholder=": , , " /> <el-select
v-model="form.unit"
filterable
allow-create
default-first-option
placeholder="请选择或输入计量单位"
style="width: 100%"
>
<el-option
v-for="item in unitOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="可见等级" prop="visibilityLevel"> <el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" /> <el-input v-model="form.spec" placeholder="请输入规格型号" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -494,6 +512,7 @@
> >
<template #prefix><el-icon><Link /></el-icon></template> <template #prefix><el-icon><Link /></el-icon></template>
</el-input> </el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')"> <el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
@ -547,6 +566,7 @@
> >
<template #prefix><el-icon><Link /></el-icon></template> <template #prefix><el-icon><Link /></el-icon></template>
</el-input> </el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
<el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')"> <el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')">
@ -565,10 +585,10 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"> <el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false">
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /> <img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog> </el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<WebRtcCamera <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@ -584,7 +604,7 @@
/> />
<!-- 预警设置弹窗 --> <!-- 预警设置弹窗 -->
<el-dialog v-model="warningDialog.visible" :title="warningDialog.title" width="500px" append-to-body destroy-on-close> <el-dialog v-model="warningDialog.visible" :title="warningDialog.title" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px"> <el-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
<el-alert <el-alert
v-if="warningDialog.selectedCount > 1" v-if="warningDialog.selectedCount > 1"
@ -620,7 +640,7 @@
</el-dialog> </el-dialog>
<!-- 批量质检设置弹窗 --> <!-- 批量质检设置弹窗 -->
<el-dialog v-model="inspectionDialog.visible" title="批量质检设置" width="500px" append-to-body destroy-on-close> <el-dialog v-model="inspectionDialog.visible" title="批量质检设置" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-alert <el-alert
:title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`" :title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`"
type="info" type="info"
@ -652,7 +672,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'; import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture } from '@element-plus/icons-vue'; import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture } 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 { useUserStore } from '@/stores/user';
@ -669,7 +689,8 @@ import {
exportAssetStatistics, exportAssetStatistics,
batchSetWarning, batchSetWarning,
batchSetInspection, batchSetInspection,
markWarningOrdered markWarningOrdered,
getMaterialUnitsAPI
} from '@/api/material_base'; } from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload'; import { usePasteUpload } from '@/hooks/usePasteUpload';
@ -743,7 +764,7 @@ const fieldOptions = computed(() => {
const allFields = [ const allFields = [
{ value: 'companyName', label: '所属公司', perm: 'material_list:companyName' }, { value: 'companyName', label: '所属公司', perm: 'material_list:companyName' },
{ value: 'name', label: '名称', perm: 'material_list:name' }, { value: 'name', label: '名称', perm: 'material_list:name' },
{ value: 'commonName', label: '俗名', perm: 'material_list:commonName' }, { value: 'commonName', label: '专业名称', perm: 'material_list:commonName' },
{ value: 'category', label: '类别', perm: 'material_list:category' }, { value: 'category', label: '类别', perm: 'material_list:category' },
{ value: 'type', label: '类型', perm: 'material_list:type' }, { value: 'type', label: '类型', perm: 'material_list:type' },
{ value: 'spec', label: '规格型号', perm: 'material_list:spec' }, { value: 'spec', label: '规格型号', perm: 'material_list:spec' },
@ -1003,6 +1024,7 @@ const hasFieldPermission = (field: string) => {
const companyOptions = ref<string[]>([]); const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]); const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]); const typeOptions = ref<string[]>([]);
const unitOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]); const categoryTreeOptions = ref<CascaderOption[]>([]);
// 用于搜索栏级联选择器的数据绑定中转 // 用于搜索栏级联选择器的数据绑定中转
@ -1018,10 +1040,26 @@ const searchCategoryPath = computed({
// 类别级联选择器的 ref // 类别级联选择器的 ref
const categoryCascaderRef = ref<any>(null); const categoryCascaderRef = ref<any>(null);
// 选中类别后自动收起下拉面板 // 选中类别后1) 收起下拉面板2) 自动提取末级 Label 末尾的英文字母填入规格型号
const onCategoryChange = () => { const onCategoryChange = () => {
if (categoryCascaderRef.value) { if (!categoryCascaderRef.value) return;
// 1) 收起下拉
categoryCascaderRef.value.togglePopperVisible(false); categoryCascaderRef.value.togglePopperVisible(false);
// 2) 从末级节点 Label 末尾提取连续的英文字母/数字 (例如 "电子半成品HH" -> "HH",
// "ASD定标实验室Opt9" -> "Opt9"),写入规格型号。
// 仅在 @change 触发时赋一次值,用户可继续手动修改;未匹配到则保持原值
try {
const nodes = categoryCascaderRef.value.getCheckedNodes?.() || [];
const node = nodes[0];
const label: string = (node && node.label) || '';
const match = label.match(/[a-zA-Z0-9]+$/);
if (match) {
form.value.spec = match[0];
}
} catch (e) {
console.error('提取类别编码后缀失败', e);
} }
}; };
@ -1127,6 +1165,17 @@ const getOptionsList = () => {
}); });
}; };
// 获取计量单位字典(新增/编辑弹窗下拉历史)
const fetchUnitList = () => {
getMaterialUnitsAPI().then((res: any) => {
if (res.code === 200) {
unitOptions.value = res.data || [];
}
}).catch(err => {
console.error("获取计量单位字典失败", err);
});
};
const querySearchCompany = (queryString: string, cb: any) => { const querySearchCompany = (queryString: string, cb: any) => {
const results = queryString const results = queryString
? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase())) ? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
@ -1321,6 +1370,23 @@ const handleEdit = (row: MaterialBaseVO) => {
}); });
}; };
// 另存为新项:把当前编辑项的数据复制一份,转为"新增"模式提交
const handleSaveAs = () => {
if (!form.value.id) return; // 防御:新增模式下不该看到此按钮
// 1. 清除主键submitForm 用 form.value.id 判空决定走 add / update 接口
delete form.value.id;
// 2. 切换弹窗标题(项目沿用 dialog.title 命名,无 dialogType / isEdit 变量)
dialog.title = '新增基础信息';
// 3. 清空脏检查基准:让 submitForm 走"完整 payload"分支(新增模式)
originalForm.value = null;
// 4. 提示用户
ElMessage.success('已成功复制当前数据,已切换至【新增】模式。请修改特定信息(如规格型号)后点击确定保存。');
};
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => { const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try { try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' }); const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
@ -1674,8 +1740,21 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
if (res.code === 200) { if (res.code === 200) {
const newUrl = res.data.url const newUrl = res.data.url
form.value[targetField].push(newUrl) form.value[targetField].push(newUrl)
// 清理 el-upload 内部 push 的"待上传"占位条目(带 raw 属性的那条 blob URL 占位),
// 否则会与下方手动 push 的新条目重复显示
const targetList = targetField === 'generalImage' ? fileListImage : fileListManual
const staleIndex = targetList.value.findIndex(f => f.raw === file)
if (staleIndex !== -1) targetList.value.splice(staleIndex, 1)
// 手动构造带服务端 URL 的条目并 pushpicture-card 即可正常渲染
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
if (targetField === 'generalImage') {
fileListImage.value.push(fileObj)
} else {
fileListManual.value.push(fileObj)
}
ElMessage.success('上传成功') ElMessage.success('上传成功')
onSuccess(res) // el-upload v-model 自动更新 fileList无需手动 push
} else { } else {
ElMessage.error(res.msg || '上传失败'); ElMessage.error(res.msg || '上传失败');
onError(new Error(res.msg)) onError(new Error(res.msg))
@ -1834,6 +1913,7 @@ onMounted(() => {
getList(); getList();
} }
getOptionsList(); getOptionsList();
fetchUnitList();
// 2. 修复弹窗锁定逻辑 // 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query); console.log('--- 准备检测外部跳转参数 ---', route.query);

View File

@ -64,7 +64,7 @@
/> />
<!-- ========== 新建/编辑弹窗 ========== --> <!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form ref="formRef" :model="form" label-width="110px"> <el-form ref="formRef" :model="form" label-width="110px">
<el-row :gutter="20"> <el-row :gutter="20">
@ -74,14 +74,14 @@
v-model="materialBaseId" v-model="materialBaseId"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
clearable clearable
placeholder="输入名称或规格搜索..." placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced" :remote-method="handleSearchMaterialDebounced"
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option="true"
popper-class="long-dropdown" popper-class="long-dropdown"
v-loadmore="handleLoadMoreMaterials" v-loadmore="handleLoadMoreMaterials"
@visible-change="onMaterialDropdownVisibleChange" @visible-change="onMaterialDropdownVisibleChange"
@ -171,7 +171,7 @@
</el-dialog> </el-dialog>
<!-- ========== 详情弹窗 ========== --> <!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close> <el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item> <el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
@ -215,7 +215,7 @@
</el-dialog> </el-dialog>
<!-- ========== 驳回原因弹窗 ========== --> <!-- ========== 驳回原因弹窗 ========== -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close> <el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form label-width="80px"> <el-form label-width="80px">
<el-form-item label="申请单号"> <el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span> <span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
@ -396,14 +396,17 @@ const handleSearchMaterialDebounced = (query: string) => {
} }
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query searchKeyword.value = safeQuery
searchPage.value = 1 searchPage.value = 1
materialOptions.value = [] materialOptions.value = []
hasNextPage.value = true hasNextPage.value = true
try { try {
const res: any = await searchMaterialPurchase(query, 1) const res: any = await searchMaterialPurchase(safeQuery, 1)
materialOptions.value = res.data || [] materialOptions.value = res.data || []
hasNextPage.value = res.has_next !== false hasNextPage.value = res.has_next !== false
} finally { } finally {
@ -431,9 +434,14 @@ const handleLoadMoreMaterials = async () => {
} }
const onMaterialDropdownVisibleChange = (visible: boolean) => { const onMaterialDropdownVisibleChange = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) { if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('') handleSearchMaterial('')
}
} }
const onMaterialSelected = (id: number | null) => { const onMaterialSelected = (id: number | null) => {

View File

@ -48,17 +48,17 @@
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
class="filter-item-select" class="filter-item-select"
clearable clearable
filterable filterable
style="width: 220px;"
@change="fetchData" @change="fetchData"
style="width: 160px;" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
@ -264,7 +264,7 @@
:width="'min(1000px, 95vw)'" :width="'min(1000px, 95vw)'"
top="4vh" top="4vh"
destroy-on-close destroy-on-close
:close-on-click-modal="!isUploading" :close-on-click-modal="false"
:close-on-press-escape="!isUploading" :close-on-press-escape="!isUploading"
:show-close="!isUploading" :show-close="!isUploading"
class="stylish-dialog compact-layout" class="stylish-dialog compact-layout"
@ -294,31 +294,21 @@
<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="12"> <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-autocomplete
v-model="form.base_id" v-model="materialNameInput"
filterable :fetch-suggestions="fetchMaterialSuggestions"
remote :value-key="'name'"
reserve-keyword
clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterialDebounced" :trigger-on-focus="true"
@visible-change="handleMaterialDropdownVisible" clearable
:loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @select="onMaterialSelected"
default-first-option @clear="onMaterialClear"
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
> >
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
</template> </template>
<el-option <template #default="{ item }">
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="option-item"> <div class="option-item">
<div class="opt-main"> <div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -332,11 +322,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div> </div>
</el-option> </template>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;"> </el-autocomplete>
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
@ -651,8 +638,8 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog> <el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<WebRtcCamera <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@ -660,7 +647,7 @@
/> />
</el-dialog> </el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body> <el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;"> <div style="text-align: center;">
<div v-loading="printLoading" class="preview-box"> <div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -819,6 +806,17 @@ const isUploading = ref(false)
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) const companyOptions = ref<string[]>([])
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
const queryParams = reactive({ const queryParams = reactive({
page: 1, page: 1,
@ -835,11 +833,8 @@ const queryParams = reactive({
advancedFilters: [] as any[] advancedFilters: [] as any[]
}) })
const materialNameInput = ref('')
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
const printVisible = ref(false) const printVisible = ref(false)
const printLoading = ref(false) const printLoading = ref(false)
@ -1125,69 +1120,50 @@ const querySearchCurrency = (queryString: string, cb: any) => {
cb(filtered) cb(filtered)
} }
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') } const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query, 1) const res: any = await searchMaterialBase(safeQuery)
if (res.data) { if (res.code === 200 && res.data) {
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false})) cb((res.data || []).map((i: any) => ({ ...i, isHistory: false })))
materialOptions.value = apiResults
hasNextPage.value = res.has_next
}
} finally { searchLoading.value = false }
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next
} else { } else {
hasNextPage.value = false cb([])
} }
} catch (e) { } catch (e) {
searchPage.value -= 1 cb([])
} finally { } finally {
loadingMore.value = false searchLoading.value = false
} }
} }
const onMaterialSelected = async (val: number) => { const onMaterialClear = () => {
const item = materialOptions.value.find(i => i.id === val) form.base_id = undefined
if (item) { form.company_name = ''
form.material_name = ''
form.spec_model = ''
form.category = ''
form.unit = ''
form.material_type = ''
isCurrentMaterialInspectionRequired.value = false
updateInspectionRules()
}
const onMaterialSelected = async (item: any) => {
form.base_id = item.id
form.company_name = item.company_name form.company_name = item.company_name
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.category = item.category form.category = item.category
form.unit = item.unit form.unit = item.unit
form.material_type = item.type form.material_type = item.type
// 保存强制质检标记 materialNameInput.value = item.name
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
// 更新表单校验规则
updateInspectionRules() updateInspectionRules()
checkHistoryAndSetMode(item.id) checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口)
try { try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: val } }) const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) { if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
@ -1195,7 +1171,6 @@ const onMaterialSelected = async (val: number) => {
} catch (e) { } catch (e) {
console.error('获取历史库位失败', e) console.error('获取历史库位失败', e)
} }
}
} }
// 动态更新质检相关校验规则 // 动态更新质检相关校验规则
@ -1383,6 +1358,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions() const res: any = await getFilterOptions()
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies companyOptions.value = res.data.companies
} }
@ -1391,6 +1367,30 @@ const fetchOptions = async () => {
} }
} }
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
const buildCategoryTree = (categories: string[]) => {
const root: { value: string; label: string; children?: any[] }[] = [];
categories.forEach((cat: string) => {
if (!cat) return;
const parts = cat.split('/');
let currentLevel = root;
parts.forEach((part, index) => {
let existingNode = currentLevel.find(n => n.value === part);
if (!existingNode) {
existingNode = { value: part, label: part };
currentLevel.push(existingNode);
}
if (index < parts.length - 1) {
if (!existingNode.children) {
existingNode.children = [];
}
currentLevel = existingNode.children as any[];
}
});
});
return root;
};
// 加载库位树数据 // 加载库位树数据
const loadWarehouseTree = async () => { const loadWarehouseTree = async () => {
try { try {
@ -1459,6 +1459,7 @@ const handleUpdate = (row: any) => {
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' } if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' } else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isInspectionRequired: row.isInspectionRequired }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isInspectionRequired: row.isInspectionRequired }]
materialNameInput.value = row.material_name
// 设置强制质检标记 // 设置强制质检标记
isCurrentMaterialInspectionRequired.value = row.isInspectionRequired || false isCurrentMaterialInspectionRequired.value = row.isInspectionRequired || false
updateInspectionRules() updateInspectionRules()
@ -1515,8 +1516,10 @@ const submitForm = async () => {
await fetchData() await fetchData()
visible.value = false visible.value = false
} catch (e: any) { } catch (error: any) {
ElMessage.error(e.msg || '操作失败') // 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
ElMessage.error(errorMsg)
} finally { submitting.value = false } } finally { submitting.value = false }
} else { } else {
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!') ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1751,8 +1754,7 @@ const confirmPrint = async () => {
} }
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = '' materialOptions.value = []; materialNameInput.value = ''; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = '';
// 重置强制质检标记 // 重置强制质检标记
isCurrentMaterialInspectionRequired.value = false isCurrentMaterialInspectionRequired.value = false
Object.assign(form, { Object.assign(form, {

View File

@ -47,17 +47,17 @@
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
class="filter-item-select" class="filter-item-select"
clearable clearable
filterable filterable
style="width: 220px;"
@change="fetchData" @change="fetchData"
style="width: 160px;" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
@ -259,7 +259,7 @@
@current-change="fetchData" @current-change="fetchData"
/> />
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="!isUploading" :close-on-press-escape="!isUploading" :show-close="!isUploading" class="stylish-dialog compact-layout"> <el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="false" :close-on-press-escape="!isUploading" :show-close="!isUploading" 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">
@ -283,24 +283,21 @@
<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="12"> <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-autocomplete
v-model="form.base_id" v-model="materialNameInput"
filterable :fetch-suggestions="fetchMaterialSuggestions"
remote :value-key="'name'"
reserve-keyword
clearable clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
:trigger-on-focus="true"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @select="onMaterialSelected"
default-first-option @clear="onMaterialClear"
v-loadmore="loadMoreMaterials"
popper-class="product-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"> <template #default="{ item }">
<div class="option-item"> <div class="option-item">
<div class="opt-main"> <div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -314,11 +311,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div> </div>
</el-option> </template>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;"> </el-autocomplete>
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
@ -460,11 +454,14 @@
v-model="form.bom_code" v-model="form.bom_code"
filterable filterable
remote remote
reserve-keyword="true"
clearable clearable
placeholder="搜规格/编号" :disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom" :remote-method="handleSearchBom"
:loading="bomSearchLoading" :loading="bomSearchLoading"
@change="handleBomSelect" @change="handleBomSelect"
default-first-option="true"
style="width: 100%" style="width: 100%"
> >
<el-option <el-option
@ -544,10 +541,10 @@
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"> <el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false">
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /> <img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog> </el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<WebRtcCamera <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@ -555,7 +552,7 @@
/> />
</el-dialog> </el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body> <el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;"> <div style="text-align: center;">
<div v-loading="printLoading" class="preview-box"> <div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -622,28 +619,6 @@ const debounce = (fn: Function, delay: number = 500) => {
} }
// ------------------------------------ // ------------------------------------
// v-loadmore
// ------------------------------------
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
// 这里的 .product-dropdown 是唯一标识,防止和采购/半成品页面冲突
const dropDownWrap = document.querySelector('.product-dropdown .el-select-dropdown__wrap')
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) {
binding.value()
}
})
}
}
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
}
}
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
@ -664,7 +639,6 @@ const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
const searchLoading = ref(false) const searchLoading = ref(false)
const loadingMore = ref(false)
const dialogStatus = ref<'create' | 'update'>('create') const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
@ -675,6 +649,18 @@ const isUploading = ref(false)
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] }) const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增] const companyOptions = ref<string[]>([]) // [新增]
const advancedFilterVisible = ref(false) const advancedFilterVisible = ref(false)
@ -723,11 +709,7 @@ const operatorOptions = ref([
{ label: '大于等于', value: '>=' }, { label: '大于等于', value: '>=' },
{ label: '小于等于', value: '<=' }, { label: '小于等于', value: '<=' },
]) ])
const materialOptions = ref<any[]>([]) const materialNameInput = ref('')
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// BOM 搜索相关 // BOM 搜索相关
const bomSearchLoading = ref(false) const bomSearchLoading = ref(false)
@ -767,6 +749,7 @@ const allColumns = [
{ prop: 'company_name', label: '所属公司', minWidth: '100', sortable: true }, // [新增] { prop: 'company_name', label: '所属公司', minWidth: '100', sortable: true }, // [新增]
{ prop: 'material_name', label: '名称', minWidth: '140', sortable: true }, { prop: 'material_name', label: '名称', minWidth: '140', sortable: true },
{ prop: 'sku', label: 'SKU', minWidth: '110', sortable: true }, { prop: 'sku', label: 'SKU', minWidth: '110', sortable: true },
{ prop: 'warehouse_loc', label: '库位', minWidth: '120', sortable: true },
{ prop: 'serial_number', label: '序列号', minWidth: '130', sortable: true }, { prop: 'serial_number', label: '序列号', minWidth: '130', sortable: true },
{ prop: 'qty_stock', label: '库存', minWidth: '90', sortable: true }, { prop: 'qty_stock', label: '库存', minWidth: '90', sortable: true },
{ prop: 'status', label: '状态', minWidth: '90', sortable: true }, { prop: 'status', label: '状态', minWidth: '90', sortable: true },
@ -943,9 +926,15 @@ const form = reactive({
// BOM Search Logic // BOM Search Logic
// ------------------------------------ // ------------------------------------
const handleSearchBom = async (query: string) => { const handleSearchBom = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
bomSearchLoading.value = true bomSearchLoading.value = true
try { try {
const res: any = await searchBom(query) const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || [] bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false } } finally { bomSearchLoading.value = false }
} }
@ -1041,73 +1030,52 @@ const rules = {
} }
// Material Search & Population Logic
// ------------------------------------ // ------------------------------------
// Material Search & Population Logic (已修改) const fetchMaterialSuggestions = (query: string, cb: (results: any[]) => void) => {
// ------------------------------------ const rawQuery = String(query || '')
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') } const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query searchMaterialBase(safeQuery).then((res: any) => {
searchPage.value = 1 const items = res.data?.items || res.data || []
materialOptions.value = [] const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name, isHistory: false }))
cb(formatted)
try { }).catch(() => cb([])).finally(() => { searchLoading.value = false })
const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
} }
const loadMoreMaterials = async () => { const onMaterialClear = () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return form.base_id = undefined
loadingMore.value = true form.company_name = ''
searchPage.value += 1 form.material_name = ''
try { form.spec_model = ''
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value) form.material_type = ''
if (res.data && res.data.items && res.data.items.length > 0) { form.category = ''
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false})) form.unit = ''
materialOptions.value.push(...newItems) form.bom_code = ''
hasNextPage.value = res.data.has_next form.bom_version = ''
} else { bomOptions.value = []
hasNextPage.value = false
}
} catch (e) {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
} }
const onMaterialSelected = async (val: number) => { const onMaterialSelected = (item: any) => {
const item = materialOptions.value.find(i => i.id === val) form.base_id = item.id
if (item) { form.company_name = item.company_name
form.company_name = item.company_name // [新增]
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.material_type = item.type form.material_type = item.type
form.category = item.category form.category = item.category
form.unit = item.unit form.unit = item.unit
materialNameInput.value = item.name
// 获取该物料历史入库库位(新增独立接口) // 切换物料时清空已选 BOM防止脏数据
try { form.bom_code = ''
const res = await request.get('/v1/inbound/product/last-location', { params: { base_id: val } }) form.bom_version = ''
if (res.code === 200 && res.data.location) { bomOptions.value = []
// 获取该物料历史入库库位
request.get('/v1/inbound/product/last-location', { params: { base_id: item.id } }).then((res: any) => {
if (res.code === 200 && res.data?.location) {
form.warehouse_location = res.data.location form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
} }
} catch (e) { }).catch(() => {})
console.error('获取历史库位失败', e)
}
}
} }
// ------------------------------------ // ------------------------------------
@ -1157,6 +1125,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions() const res: any = await getFilterOptions()
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增] companyOptions.value = res.data.companies // [新增]
} }
@ -1165,6 +1134,30 @@ const fetchOptions = async () => {
} }
} }
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
const buildCategoryTree = (categories: string[]) => {
const root: { value: string; label: string; children?: any[] }[] = [];
categories.forEach((cat: string) => {
if (!cat) return;
const parts = cat.split('/');
let currentLevel = root;
parts.forEach((part, index) => {
let existingNode = currentLevel.find(n => n.value === part);
if (!existingNode) {
existingNode = { value: part, label: part };
currentLevel.push(existingNode);
}
if (index < parts.length - 1) {
if (!existingNode.children) {
existingNode.children = [];
}
currentLevel = existingNode.children as any[];
}
});
});
return root;
};
// 加载库位树数据 // 加载库位树数据
const loadWarehouseTree = async () => { const loadWarehouseTree = async () => {
try { try {
@ -1232,7 +1225,6 @@ const handleCreate = () => {
resetForm() resetForm()
form.in_date = dayjs().format('YYYY-MM-DD') form.in_date = dayjs().format('YYYY-MM-DD')
visible.value = true visible.value = true
materialOptions.value = []
} }
const handleUpdate = (row: any) => { const handleUpdate = (row: any) => {
@ -1261,7 +1253,7 @@ const handleUpdate = (row: any) => {
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })) inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r)) const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : '' inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }] materialNameInput.value = row.material_name
// 回显BOM // 回显BOM
if (form.bom_code) { if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }] bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -1406,11 +1398,13 @@ const submitForm = async () => {
const res: any = await createProductInbound(payload) const res: any = await createProductInbound(payload)
ElMessage.success('入库成功') ElMessage.success('入库成功')
const newItem = res.data const newItem = res.data
if (newItem) { ElMessage.info('发送打印...'); try { await executePrint({ ...newItem, copies: form.print_copies }); ElMessage.success(`指令已发送 (x${form.print_copies})`) } catch (e: any) { ElMessage.warning('打印失败') } } if (newItem) { ElMessage.info('发送打印...'); try { const printPayload = { ...newItem, warehouse_loc: form.warehouse_location || newItem.warehouse_location || newItem.warehouse_loc || '未分配', copies: form.print_copies }; await executePrint(printPayload); ElMessage.success(`指令已发送 (x${form.print_copies})`) } catch (e: any) { ElMessage.warning('打印失败') } }
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
visible.value = false; fetchData() visible.value = false; fetchData()
} catch(e:any) { } catch(error:any) {
ElMessage.error(e.msg || '操作失败') // 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
ElMessage.error(errorMsg)
} finally { submitting.value = false } } finally { submitting.value = false }
} else { } else {
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!') ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1454,12 +1448,12 @@ const handleBeforeRemove = (uploadFile, uploadFiles) => {
const handlePrint = async (row: any) => { const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = '' printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, sku: row.sku } currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_location || row.warehouse_loc || '未分配', serial_number: row.serial_number, sku: row.sku }
try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false } try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false }
} }
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } } const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); 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 = '' materialNameInput.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, print_copies: 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: '' }) 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, print_copies: 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')

View File

@ -48,17 +48,17 @@
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
class="filter-item-select" class="filter-item-select"
clearable clearable
filterable filterable
style="width: 220px;"
@change="fetchData" @change="fetchData"
style="width: 160px;" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
@ -288,7 +288,7 @@
width="min(1000px, 95vw)" width="min(1000px, 95vw)"
top="5vh" top="5vh"
destroy-on-close destroy-on-close
:close-on-click-modal="!isUploading" :close-on-click-modal="false"
:close-on-press-escape="!isUploading" :close-on-press-escape="!isUploading"
:show-close="!isUploading" :show-close="!isUploading"
class="stylish-dialog compact-layout" class="stylish-dialog compact-layout"
@ -318,29 +318,19 @@
<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="12"> <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-autocomplete
v-model="form.base_id" v-model="materialNameInput"
filterable :fetch-suggestions="fetchMaterialSuggestions"
remote :value-key="'name'"
reserve-keyword
clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial" :trigger-on-focus="true"
@visible-change="handleMaterialDropdownVisible" clearable
:loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @select="onMaterialSelected"
default-first-option @clear="onMaterialClear"
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
> >
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
<el-option <template #default="{ item }">
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="option-item"> <div class="option-item">
<div class="opt-main"> <div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -354,11 +344,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div> </div>
</el-option> </template>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;"> </el-autocomplete>
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
@ -525,11 +512,14 @@
v-model="form.bom_code" v-model="form.bom_code"
filterable filterable
remote remote
reserve-keyword="true"
clearable clearable
placeholder="搜规格/编号" :disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom" :remote-method="handleSearchBom"
:loading="bomSearchLoading" :loading="bomSearchLoading"
@change="handleBomSelect" @change="handleBomSelect"
default-first-option="true"
style="width: 100%" style="width: 100%"
> >
<el-option <el-option
@ -603,15 +593,15 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog> <el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<WebRtcCamera <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@cancel="cameraDialogVisible = false" @cancel="cameraDialogVisible = false"
/> />
</el-dialog> </el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body> <el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;"> <div style="text-align: center;">
<div v-loading="printLoading" class="preview-box"> <div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -728,6 +718,18 @@ const isUploading = ref(false)
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] }) const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增] const companyOptions = ref<string[]>([]) // [新增]
const advancedFilterVisible = ref(false) const advancedFilterVisible = ref(false)
@ -776,11 +778,8 @@ const operatorOptions = ref([
{ label: '大于等于', value: '>=' }, { label: '大于等于', value: '>=' },
{ label: '小于等于', value: '<=' }, { label: '小于等于', value: '<=' },
]) ])
const materialNameInput = ref('')
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// BOM 搜索相关 // BOM 搜索相关
const bomSearchLoading = ref(false) const bomSearchLoading = ref(false)
@ -990,9 +989,15 @@ watch(
// BOM Search Logic // BOM Search Logic
// ------------------------------------ // ------------------------------------
const handleSearchBom = async (query: string) => { const handleSearchBom = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
bomSearchLoading.value = true bomSearchLoading.value = true
try { try {
const res: any = await searchBom(query) const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || [] bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false } } finally { bomSearchLoading.value = false }
} }
@ -1038,63 +1043,52 @@ const handleManagerSelect = (item: any) => {
// ------------------------------------ // ------------------------------------
// Material Search (Matches Buy.vue) // Material Search (Matches Buy.vue)
// ------------------------------------ // ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') } const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query, 1) const res: any = await searchMaterialBase(safeQuery)
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false})) if (res.code === 200 && res.data) {
materialOptions.value = apiResults cb((res.data?.items || res.data || []).map((i: any) => ({ ...i, isHistory: false })))
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.data.has_next
} else { } else {
hasNextPage.value = false cb([])
} }
} catch (e) { } catch (e) {
searchPage.value -= 1 cb([])
} finally { } finally {
loadingMore.value = false searchLoading.value = false
} }
} }
const onMaterialSelected = async (val: number) => { const onMaterialClear = () => {
const item = materialOptions.value.find(i => i.id === val) form.base_id = undefined
if (item) { form.company_name = ''
form.company_name = item.company_name // [新增] form.material_name = ''
form.spec_model = ''
form.category = ''
form.unit = ''
form.material_type = ''
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
}
const onMaterialSelected = async (item: any) => {
form.base_id = item.id
form.company_name = item.company_name
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.category = item.category form.category = item.category
form.unit = item.unit form.unit = item.unit
form.material_type = item.type form.material_type = item.type
materialNameInput.value = item.name
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
checkHistoryAndSetMode(item.id) checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口)
try { try {
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: val } }) const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) { if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
@ -1102,7 +1096,6 @@ const onMaterialSelected = async (val: number) => {
} catch (e) { } catch (e) {
console.error('获取历史库位失败', e) console.error('获取历史库位失败', e)
} }
}
} }
// ------------------------------------ // ------------------------------------
@ -1248,6 +1241,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions() const res: any = await getFilterOptions()
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增] companyOptions.value = res.data.companies // [新增]
} }
@ -1256,6 +1250,30 @@ const fetchOptions = async () => {
} }
} }
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
const buildCategoryTree = (categories: string[]) => {
const root: { value: string; label: string; children?: any[] }[] = [];
categories.forEach((cat: string) => {
if (!cat) return;
const parts = cat.split('/');
let currentLevel = root;
parts.forEach((part, index) => {
let existingNode = currentLevel.find(n => n.value === part);
if (!existingNode) {
existingNode = { value: part, label: part };
currentLevel.push(existingNode);
}
if (index < parts.length - 1) {
if (!existingNode.children) {
existingNode.children = [];
}
currentLevel = existingNode.children as any[];
}
});
});
return root;
};
// 加载库位树数据 // 加载库位树数据
const loadWarehouseTree = async () => { const loadWarehouseTree = async () => {
try { try {
@ -1346,6 +1364,7 @@ const handleUpdate = (row: any) => {
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' } if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' } else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
materialNameInput.value = row.material_name
// 回显BOM如果存在 // 回显BOM如果存在
if (form.bom_code) { if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }] bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -1487,8 +1506,10 @@ const submitForm = async () => {
} }
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
await fetchData(); visible.value = false await fetchData(); visible.value = false
} catch (e: any) { } catch (error: any) {
ElMessage.error(e.msg || '操作失败') // 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
ElMessage.error(errorMsg)
} finally { submitting.value = false } } finally { submitting.value = false }
} else { } else {
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!') ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1537,7 +1558,7 @@ const handlePrint = async (row: any) => {
} }
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`指令已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } } const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`指令已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = '' materialOptions.value = []; materialNameInput.value = ''; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
Object.assign(form, { Object.assign(form, {
id: undefined, base_id: undefined, id: undefined, base_id: undefined,
company_name: '', // [新增] company_name: '', // [新增]

View File

@ -85,6 +85,8 @@
:title="dialogTitle" :title="dialogTitle"
width="700px" width="700px"
destroy-on-close destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="resetDialog" @close="resetDialog"
> >
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
@ -103,14 +105,14 @@
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
placeholder="输入名称或规格..." placeholder="输入名称或规格..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option="true"
> >
<el-option <el-option
v-for="item in materialOptions" v-for="item in materialOptions"
@ -269,6 +271,7 @@ const perPage = ref(20)
const total = ref(0) const total = ref(0)
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchKeyword = ref('')
const searchLoading = ref(false) const searchLoading = ref(false)
const searchForm = reactive({ const searchForm = reactive({
@ -329,15 +332,31 @@ const handlePageChange = (val: number) => {
} }
const handleMaterialDropdownVisible = (visible: boolean) => { const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) { if (!visible) return
// 防御性拦截 1用户已选过物料form.base_id 有值)
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
// 否则会清空 searchKeyword 和 materialOptions破坏用户正在编辑的搜索结果。
if (form.base_id) return
// 防御性拦截 2已经有搜索关键字或已经有下拉数据
// 同样不要重置、不要再请求默认列表
if (searchKeyword.value || materialOptions.value.length > 0) return
handleSearchMaterial('') handleSearchMaterial('')
}
} }
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
// 防御性拦截el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
searchKeyword.value = safeQuery
searchLoading.value = true searchLoading.value = true
try { try {
const res = await searchMaterialBase(query) const res = await searchMaterialBase(safeQuery)
if (res.code === 200) { if (res.code === 200) {
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false })) const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults materialOptions.value = apiResults

View File

@ -12,6 +12,57 @@
</div> </div>
</template> </template>
<!-- 审批单选择下拉框 -->
<div class="approval-request-select">
<el-select
v-model="selectedApprovalId"
placeholder="请选择已通过审批的借库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleApprovalChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.borrower_name || '未知借库人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过status=1的审批单</p>
</div>
<!-- 审批计划清单预览 -->
<div v-if="selectedApproval" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">审批计划清单</span>
<el-tag type="success" size="small">{{ plannedItems.length }} </el-tag>
</div>
<el-table :data="plannedItems" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="100" show-overflow-tooltip />
<el-table-column label="审批数量" width="90" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold;">{{ row.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section"> <div class="scan-section">
<div v-if="userStore.hasPermission('op_borrow:operation')" 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>
@ -204,26 +255,22 @@ 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 { getStockByBarcode } from '@/api/outbound' import { getStockByBarcode } from '@/api/outbound'
import request from '@/utils/request' import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
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 userStore = useUserStore()
// 列与权限Code的映射关系数据库中的code // 列与权限Code的映射关系
const permissionMap: Record<string, string> = { const permissionMap: Record<string, string> = {
borrower_name: 'op_borrow:borrower_name', borrower_name: 'op_borrow:borrower_name',
sku: 'op_borrow:sku', sku: 'op_borrow:sku',
available_quantity: 'op_borrow:available_quantity', available_quantity: 'op_borrow:available_quantity',
out_quantity: 'op_borrow:out_quantity', out_quantity: 'op_borrow:out_quantity',
// 其他字段可根据需要添加
} }
// 检查列权限
const hasColumnPermission = (prop: string) => { const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') { if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
return true
}
const code = permissionMap[prop] const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false return code ? userStore.hasPermission(code) : false
} }
@ -236,6 +283,76 @@ const showCamera = ref(false)
const barcodeRef = ref() const barcodeRef = ref()
const formRef = ref() const formRef = ref()
// ★ 审批单选择
const approvalRequests = ref<any[]>([])
const selectedApprovalId = ref<number | null>(null)
const requestsLoading = ref(false)
const selectedApproval = computed(() =>
selectedApprovalId.value
? approvalRequests.value.find(r => r.id === selectedApprovalId.value) ?? null
: null
)
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
// ★ 加载已通过审批的借库申请单列表
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getBorrowApprovalList({ status: 1, page: 1, limit: 100 })
approvalRequests.value = res.data?.items || []
} catch (e) {
console.error('加载借库审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
// ★ 切换审批单时:清空购物车和签名,防止跨单据污染
const handleApprovalChange = (val: number | null) => {
if (!val) {
selectedApprovalId.value = null
}
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
}
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在审批计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
// 购物车中已扫的同名同规格物料累计数量
const alreadyScanned = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || '').trim()
return ciName === normalizedName && ciSpec === normalizedSpec
})
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
if (alreadyScanned + scannedQty > planQty) {
return `${normalizedName} × ${normalizedSpec}】超出审批数量(审批: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty}`
}
return null
}
// 签名相关 // 签名相关
const showSignatureDialog = ref(false) const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('') const signaturePreviewUrl = ref('')
@ -254,9 +371,7 @@ const form = reactive({
}) })
const rules = computed(() => ({ const rules = computed(() => ({
borrower_name: [ borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }],
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
],
expected_return_time: [ expected_return_time: [
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' } { required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
] ]
@ -265,9 +380,7 @@ const rules = computed(() => ({
const isIndefinite = ref(false) const isIndefinite = ref(false)
const handleIndefiniteChange = (val: boolean) => { const handleIndefiniteChange = (val: boolean) => {
if (val) { if (val) form.expected_return_time = ''
form.expected_return_time = ''
}
} }
const disabledDate = (time: Date) => { const disabledDate = (time: Date) => {
@ -278,35 +391,51 @@ const disabledDate = (time: Date) => {
const onScanSuccess = (code: string) => { const onScanSuccess = (code: string) => {
if (!code) return if (!code) return
const trimCode = code.trim() const trimCode = code.trim()
const validPattern = /^[A-Za-z0-9\-\.]+$/ const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) { if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`) ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return return
} }
if (trimCode.length < 3) { if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试') ElMessage.warning('扫描结果过短,请对准重试')
return return
} }
if (loading.value) return if (loading.value) return
barcodeInput.value = trimCode barcodeInput.value = trimCode
handleManualInput() handleManualInput()
} }
const handleManualInput = async () => { const handleManualInput = async () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
const code = barcodeInput.value.trim() const code = barcodeInput.value.trim()
if (!code) return if (!code) return
// ★ 必须先选择审批单
if (!selectedApproval.value) {
ElMessage.warning('请先选择要执行借库的审批申请单')
return
}
try { try {
loading.value = true loading.value = true
// 查重 // 查重:条码或 SKU 匹配已扫记录
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code) const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) { if (existIndex > -1) {
const item = cartItems.value[existIndex] const item = cartItems.value[existIndex]
// ★ 追加前仍需校验审批数量上限
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const maxQty = parseFloat(item.available_quantity) const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) { if (item.out_quantity < maxQty) {
item.out_quantity++ item.out_quantity++
@ -314,6 +443,7 @@ const handleManualInput = async () => {
if (navigator.vibrate) navigator.vibrate(50) if (navigator.vibrate) navigator.vibrate(50)
} else { } else {
ElMessage.warning(`库存不足 (余: ${maxQty})`) ElMessage.warning(`库存不足 (余: ${maxQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
} }
barcodeInput.value = '' barcodeInput.value = ''
return return
@ -326,8 +456,21 @@ const handleManualInput = async () => {
const availQty = parseFloat(item.available_quantity || 0) const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) { if (availQty <= 0) {
ElMessage.warning(`库存不足 (余: ${availQty})`) ElMessage.warning(`库存不足或已借出 (余: ${availQty})`)
} else { if (navigator.vibrate) navigator.vibrate([100, 50, 100])
barcodeInput.value = ''
return
}
// ★ 扫码加入前强校验:不在清单内或超量直接阻断
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
cartItems.value.push({ cartItems.value.push({
...item, ...item,
out_quantity: 1, out_quantity: 1,
@ -335,7 +478,6 @@ const handleManualInput = async () => {
}) })
ElMessage.success(`添加成功: ${item.name}`) ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100) if (navigator.vibrate) navigator.vibrate(100)
}
barcodeInput.value = '' barcodeInput.value = ''
} }
} catch (error: any) { } catch (error: any) {
@ -344,9 +486,9 @@ const handleManualInput = async () => {
} else { } else {
ElMessage.error('查询出错') ElMessage.error('查询出错')
} }
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
} finally { } finally {
loading.value = false loading.value = false
// ★ 核心修改:只有当非全屏模式时,才自动聚焦输入框
if (!showCamera.value) { if (!showCamera.value) {
nextTick(() => { barcodeRef.value?.focus() }) nextTick(() => { barcodeRef.value?.focus() })
} }
@ -354,10 +496,18 @@ const handleManualInput = async () => {
} }
const removeFromCart = (index: number) => { const removeFromCart = (index: number) => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
cartItems.value.splice(index, 1) cartItems.value.splice(index, 1)
} }
const clearAll = () => { const clearAll = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' }) ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
.then(() => { .then(() => {
cartItems.value = [] cartItems.value = []
@ -368,13 +518,19 @@ const clearAll = () => {
signaturePreviewUrl.value = '' signaturePreviewUrl.value = ''
barcodeInput.value = '' barcodeInput.value = ''
isIndefinite.value = false isIndefinite.value = false
// 仅清空购物车,保留审批单选择
}) })
} }
// --- 提交逻辑 --- // --- 提交逻辑 ---
const submitForm = async () => { const submitForm = async () => {
if (!userStore.hasPermission('op_borrow: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('请先添加物品')
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {
if (!valid) { if (!valid) {
@ -382,7 +538,6 @@ const submitForm = async () => {
ElMessage.error(requiredMsg) ElMessage.error(requiredMsg)
return return
} }
if (!signatureFile.value) { if (!signatureFile.value) {
ElMessage.error('请领用人进行电子签名') ElMessage.error('请领用人进行电子签名')
return return
@ -395,20 +550,32 @@ const submitForm = async () => {
const uploadRes = await uploadFile(signatureFile.value) const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url const signatureUrl = uploadRes.data.url
// 处理无限期借用:如果选择了无限期,将预计归还时间置为空 // ★ 规范 Payload只包含后端需要的最小字段
const submitData = { const itemsPayload = cartItems.value.map(item => {
...form, let safeQty = Number(item.out_quantity)
expected_return_time: isIndefinite.value ? null : form.expected_return_time if (isNaN(safeQty) || safeQty <= 0) safeQty = 1
return {
id: item.id || 0,
source_table: item.source_table || '',
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
out_quantity: safeQty
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交')
return
} }
await request({ await dispatchBorrow({
url: '/v1/transactions/borrow', approval_id: selectedApprovalId.value,
method: 'post', items: itemsPayload,
data: { borrower_name: form.borrower_name,
items: cartItems.value, signature_path: signatureUrl,
...submitData, remark: form.remark,
signature_path: signatureUrl expected_return_time: isIndefinite.value ? null : form.expected_return_time
}
}) })
ElMessage.success('借用成功') ElMessage.success('借用成功')
@ -431,13 +598,18 @@ const submitForm = async () => {
} }
// --- 签名逻辑 --- // --- 签名逻辑 ---
const openSignatureDialog = () => { showSignatureDialog.value = true } const openSignatureDialog = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无签名权限')
return
}
showSignatureDialog.value = true
}
const initCanvas = async () => { const initCanvas = async () => {
await nextTick() await nextTick()
const canvas = nativeCanvasRef.value const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value const container = canvasContainerRef.value
if (canvas && container) { if (canvas && container) {
canvas.width = container.clientWidth canvas.width = container.clientWidth
canvas.height = container.clientHeight canvas.height = container.clientHeight
@ -500,6 +672,12 @@ const handleSignConfirm = () => {
const handleSignCancel = () => { showSignatureDialog.value = false } const handleSignCancel = () => { showSignatureDialog.value = false }
// --- 初始化 ---
import { onMounted } from 'vue'
onMounted(() => {
loadApprovalRequests()
})
onUnmounted(() => { onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value) if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
}) })
@ -514,7 +692,16 @@ onUnmounted(() => {
.card-header { display: flex; justify-content: space-between; align-items: center; } .card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; } .title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
/* 扫码区(卡片内触发器) */ /* 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; }
/* 计划清单 */
.planned-items-section { margin-bottom: 16px; background: #f5f7fa; border-radius: 6px; padding: 12px; }
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.planned-title { font-size: 13px; font-weight: bold; color: #606266; }
/* 扫码区 */
.scan-section { margin-bottom: 20px; } .scan-section { margin-bottom: 20px; }
.camera-placeholder { .camera-placeholder {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px; height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
@ -525,59 +712,26 @@ onUnmounted(() => {
.camera-placeholder:active { background: #e6e8eb; } .camera-placeholder:active { background: #e6e8eb; }
.camera-placeholder .text { margin-top: 5px; font-size: 13px; } .camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 全屏扫码层样式 */ /* 全屏扫码层 */
.fullscreen-scanner-overlay { .fullscreen-scanner-overlay {
position: fixed; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
top: 0; background: #000; z-index: 9999; display: flex; flex-direction: column;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
} }
.scanner-header { .scanner-header {
height: 60px; height: 60px; display: flex; align-items: center; justify-content: space-between;
display: flex; padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff;
align-items: center; position: absolute; top: 0; width: 100%; z-index: 10;
justify-content: space-between;
padding: 0 15px;
background: rgba(0,0,0,0.6);
color: #fff;
position: absolute;
top: 0;
width: 100%;
z-index: 10;
} }
.scanner-title { font-size: 16px; font-weight: bold; } .scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; } .close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
.scanner-body { .scanner-body {
flex: 1; flex: 1; width: 100%; position: relative; display: flex;
width: 100%; align-items: center; justify-content: center;
position: relative;
display: flex;
align-items: center;
justify-content: center;
} }
/* 强制子组件QrScanner填满容器 */ :deep(.qr-scanner-container) { width: 100% !important; height: 100% !important; border-radius: 0 !important; }
:deep(.qr-scanner-container) {
width: 100% !important;
height: 100% !important;
border-radius: 0 !important;
}
.scanner-footer { .scanner-footer {
position: absolute; position: absolute; bottom: 0; width: 100%; padding: 20px;
bottom: 0; background: rgba(0,0,0,0.6); color: #fff; text-align: center; z-index: 10;
width: 100%;
padding: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
text-align: center;
z-index: 10;
} }
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; } .current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
@ -591,7 +745,6 @@ onUnmounted(() => {
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; } .unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; } .signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; } .re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; } .bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; } .bottom-actions .el-button { width: 48%; }

View File

@ -35,27 +35,50 @@
stripe stripe
style="margin-top:20px" style="margin-top:20px"
v-loading="loading" v-loading="loading"
type="expand"
> >
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip /> <el-table-column type="expand">
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" /> <template #default="props">
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip /> <div style="padding: 10px 40px;">
<el-table-column label="借出数量" width="90" align="center"> <h4 style="margin: 0 0 10px; font-size: 14px; color: #606266;">借出明细</h4>
<el-table :data="props.row.children" border size="small">
<el-table-column prop="material_name" label="物料名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="借出数量" width="80" align="center">
<template #default="{row}"> <template #default="{row}">
<el-tag type="info">{{ row.quantity }}</el-tag> <el-tag type="info">{{ row.quantity }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="已还数量" width="90" align="center"> <el-table-column label="已还数量" width="80" align="center">
<template #default="{row}"> <template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag> <el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="待还数量" width="90" align="center"> <el-table-column label="待还数量" width="80" align="center">
<template #default="{row}"> <template #default="{row}">
<el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag> <el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag>
<el-tag v-else type="success">0</el-tag> <el-tag v-else type="success">0</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="return_location" label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<el-table-column v-if="hasColumnPermission('borrow_time')" 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="借出物品" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.children ? row.children.length : 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('return_operator')" prop="return_operator" label="归还人" width="100" /> <el-table-column v-if="hasColumnPermission('return_operator')" prop="return_operator" label="归还人" width="100" />
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200"> <el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
@ -88,13 +111,6 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="hasColumnPermission('return_location')" label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" 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">
@ -205,7 +221,20 @@ const fetchData = async () => {
search_type: searchType.value search_type: searchType.value
} }
}) })
list.value = res.data.items
// ★ 按 borrow_no 分组聚合为主子表结构
const groupMap = new Map()
;(res.data.items || []).forEach(item => {
if (!groupMap.has(item.borrow_no)) {
groupMap.set(item.borrow_no, {
...item,
children: []
})
}
groupMap.get(item.borrow_no).children.push(item)
})
list.value = Array.from(groupMap.values())
total.value = res.data.total total.value = res.data.total
} finally { loading.value = false } } finally { loading.value = false }
} }