63 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
dxc
6149662fd8 V3.41修改AI接口 2026-06-01 11:07:24 +08:00
DXC
f18dfd9819 新增 /cascade-inventory 级联库存缺口查询接口,供 AI 调用 BOM 出库缺口分析 2026-06-01 09:59:48 +08:00
dxc
992e08aee9 V3.40 2026-06-01 09:29:03 +08:00
dxc
f27488e693 V3.39版本推送,出库选单依据BOM子件0的逻辑修改 2026-06-01 09:28:22 +08:00
dxc
034418df8a V3.38版本修改,三种入库按照基础信息内容进行修改 2026-05-29 14:26:52 +08:00
dxc
cd54ca3fe2 V3.37版本修改,基础信息图片点击边缘空白处即可关闭 2026-05-29 11:33:54 +08:00
dxc
05aff2dd83 V3.36版本修改,基础信息列展示规则,分页数量修改,类别搜索修改 2026-05-29 11:23:05 +08:00
DXC
c1d364b786 基础信息页:新增列展示本地缓存 + 全选功能 2026-05-29 10:51:52 +08:00
dxc
6e50762da6 服务器数据库端口暴露宿主机修改 2026-05-28 11:49:06 +08:00
dxc
b4945cbba4 修复服务器端打印字体丢失问题,修复上传脚本打包过大问题 2026-05-27 09:57:01 +08:00
dxc
7d828d3ebf 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 12:01:58 +08:00
dxc
7e09e9de31 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 11:28:42 +08:00
dxc
fb5b8d873b 版本变更V3.35将图像的处理统一更换到新表当中 2026-05-26 11:28:26 +08:00
dxc
682139bab8 版本变更V3.34将图像的处理统一更换到新表当中 2026-05-26 08:57:41 +08:00
DXC
e564c5a5d2 fix: 以图搜图跳转物料页面用 watch 接管查询,防止 URL 参数残留 2026-05-26 08:50:53 +08:00
DXC
9406669f1c fix: 以图搜图查看详情优先用 spec_model 跳转物料页面自动搜索 2026-05-26 08:34:03 +08:00
DXC
92e1f7275e feat: 以图搜图返回 business_data 包含 name/spec_model/url,支持详情页跳转 2026-05-25 17:52:03 +08:00
dxc
895d78a5e7 版本变更V3.33添加支持更新后识图功能 2026-05-25 11:20:45 +08:00
DXC
567c3175f6 fix: 审计日志跳过向量字段,修复 numpy 数组比较异常;补全三大入库单更新向量提取,统一删除确认弹窗 2026-05-25 11:11:10 +08:00
53 changed files with 7381 additions and 1411 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 *)"
]
}
}

Binary file not shown.

View File

@ -8,53 +8,81 @@ REMOTE_BACKUP_BASE="$REMOTE_DIR/data_copy"
REMOTE_BACKUP_DIR="$REMOTE_BACKUP_BASE/$TIMESTAMP" REMOTE_BACKUP_DIR="$REMOTE_BACKUP_BASE/$TIMESTAMP"
echo "===================================================" echo "==================================================="
echo "🚀 开始增量部署 (仅代码,不影响数据库和图片)" echo "🚀 开始自动化增量部署 (含自我修复与依赖瘦身)"
echo "===================================================" echo "==================================================="
# 1. 远端备份 (使用 sudo 提权) # 1. 远端备份与环境急救
echo "[1/4] 正在服务器上备份旧代码并清理多余备份 (可能需要输入服务器密码)..." echo "[1/4] 正在服务器上急救环境并备份旧代码 (可能需要输入密码)..."
ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \ ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
cd $REMOTE_DIR && \ cd $REMOTE_DIR && \
echo '>> 检查并修复缺失目录 (防止 tar 崩溃)...' && \
sudo mkdir -p inventory-backend inventory-web && \
echo '>> 执行代码备份...' && \
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz inventory-backend inventory-web docker-compose.prod.yml && \ sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz inventory-backend inventory-web docker-compose.prod.yml && \
echo '>> 执行清理:仅保留 data_copy 下最新的 2 个备份...' && \ echo '>> 执行清理:仅保留 data_copy 下最新的 2 个备份...' && \
cd $REMOTE_BACKUP_BASE && \ cd $REMOTE_BACKUP_BASE && \
sudo sh -c 'ls -dt */ | tail -n +3 | xargs -I {} rm -rf {}'" sudo sh -c 'ls -dt */ 2>/dev/null | tail -n +3 | xargs -I {} rm -rf {} || true'"
if [ $? -ne 0 ]; then echo "❌ 服务器备份或清理失败,终止部署!"; exit 1; fi if [ $? -ne 0 ]; then echo "❌ 服务器备份或清理失败,终止部署!"; exit 1; fi
# 2. 本地打包 (新增了精准拦截本地图片和本地数据库) # 2. 本地精准打包 (绝对屏蔽垃圾文件)
echo "[2/4] 正在本地打包新代码 (自动剔除本地图片、数据库和缓存)..." echo "[2/4] 正在本地打包新代码 (执行终极瘦身)..."
tar -czf deploy.tar.gz \ tar -czf deploy.tar.gz \
--exclude='node_modules' \ --exclude="*/node_modules" \
--exclude='.venv' \ --exclude="*/venv" \
--exclude='venv' \ --exclude="*/.venv" \
--exclude='__pycache__' \ --exclude="*/__pycache__" \
--exclude='*.pyc' \ --exclude="*.pyc" \
--exclude='.git' \ --exclude="*/.git" \
--exclude='.idea' \ --exclude="*/.idea" \
--exclude='.vscode' \ --exclude="*/.vscode" \
--exclude='pgdata_*' \ --exclude="inventory-backend/uploads" \
--exclude='uploads_*' \ --exclude="inventory-backend/models" \
--exclude='data_copy' \ --exclude="inventory-backend/pgdata" \
--exclude='inventory-backend/uploads' \ --exclude="inventory-backend/pgdata_docker" \
--exclude='inventory-backend/pgdata' \ --exclude="data_copy" \
inventory-backend inventory-web docker-compose.prod.yml inventory-backend inventory-web docker-compose.prod.yml
# 3. 传输到生产环境的 /tmp 目录 (避开权限拦截) # 检查压缩包大小是否正常 (如果超过 50MB 则发出警告)
FILESIZE=$(stat -c%s "deploy.tar.gz" 2>/dev/null || stat -f%z "deploy.tar.gz")
MB_SIZE=$((FILESIZE / 1024 / 1024))
echo ">> 打包完成,当前传输包大小: ${MB_SIZE} MB"
if [ "$MB_SIZE" -gt 50 ]; then
echo "⚠️ 警告:包体积仍大于 50MB可能存在未排除的大文件但继续执行..."
fi
# 3. 传输到生产环境的 /tmp 目录
echo "[3/4] 正在传输代码到服务器的临时目录..." echo "[3/4] 正在传输代码到服务器的临时目录..."
scp deploy.tar.gz $SERVER:/tmp/deploy.tar.gz scp deploy.tar.gz $SERVER:/tmp/deploy.tar.gz
if [ $? -ne 0 ]; then
echo "❌ 传输失败!可能是网络中断,终止部署!"
rm -f deploy.tar.gz
exit 1
fi
# 4. 服务器执行替换与重启 (修复了 docker-compose 找不到命令的问题) # 4. 服务器执行替换与重启
echo "[4/4] 正在生产环境执行热更新 (可能需要输入服务器密码)..." echo "[4/4] 正在生产环境执行热更新 (可能需要输入密码)..."
ssh -t $SERVER "cd $REMOTE_DIR && \ ssh -t $SERVER "cd $REMOTE_DIR && \
echo '>> 移除历史旧目录...' && \
sudo rm -rf inventory-backend_old inventory-web_old && \ sudo rm -rf inventory-backend_old inventory-web_old && \
echo '>> 备份当前目录为 old...' && \
(sudo mv inventory-backend inventory-backend_old 2>/dev/null || true) && \ (sudo mv inventory-backend inventory-backend_old 2>/dev/null || true) && \
(sudo mv inventory-web inventory-web_old 2>/dev/null || true) && \ (sudo mv inventory-web inventory-web_old 2>/dev/null || true) && \
echo '>> 部署新代码...' && \
sudo mv /tmp/deploy.tar.gz . && \ sudo mv /tmp/deploy.tar.gz . && \
sudo tar -xzf deploy.tar.gz && \ sudo tar -xzf deploy.tar.gz && \
echo '>> 重启 Docker 容器...' && \
sudo docker compose -f docker-compose.prod.yml up -d --build backend frontend && \ sudo docker compose -f docker-compose.prod.yml up -d --build backend frontend && \
sudo rm deploy.tar.gz" sudo rm deploy.tar.gz"
echo "===================================================" if [ $? -ne 0 ]; then
echo "✅ 部署完成!请刷新网页查看效果。" echo "❌ 远端启动失败,请检查 Docker 日志!"
echo "===================================================" else
echo "==================================================="
echo "✅ 部署完美完成!请刷新网页查看效果。"
echo "==================================================="
fi
# 扫尾清理本地文件
rm -f deploy.tar.gz

View File

@ -13,7 +13,8 @@ services:
volumes: volumes:
# 数据卷保持不变,你的历史数据不会丢失! # 数据卷保持不变,你的历史数据不会丢失!
- ./pgdata_prod:/var/lib/postgresql/data - ./pgdata_prod:/var/lib/postgresql/data
ports:
- "5432:5432"
# --- 后端 (Flask) (保持不变) --- # --- 后端 (Flask) (保持不变) ---
backend: backend:
build: build:

View File

@ -1,4 +1,3 @@
version: '3.8'
services: services:
db: db:

View File

@ -1,6 +1,12 @@
venv/ .git
__pycache__/ .idea
__pycache__
*.pyc *.pyc
.git/ *.pyo
venv
.venv
env
uploads
pgdata
.env .env
pgdata/ simhei.ttf

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
@ -382,3 +383,99 @@ def get_bom_parents():
except Exception as e: except Exception as e:
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}') current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/cascade-inventory', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_cascade_inventory():
"""
根据 BOM 编号和订单数量,计算所有子件的级联库存缺口(供 AI 调用)
Query参数:
- bom_no: BOM编号必填
- order_qty: 订单需求量(必填,数值)
"""
try:
bom_no = request.args.get('bom_no', '').strip()
order_qty_str = request.args.get('order_qty', '').strip()
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
if not order_qty_str:
return jsonify({'code': 400, 'msg': 'order_qty 不能为空'}), 400
try:
order_qty = float(order_qty_str)
except ValueError:
return jsonify({'code': 400, 'msg': 'order_qty 必须为有效数字'}), 400
data = BomService.calculate_cascade_inventory(bom_no, order_qty)
if data is None:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'级联库存计算失败: {str(e)}')
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

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索 以图搜图 API - CLIP Vision Embedding + pgvector 余弦距离检索
数据源image_embeddings 表(统一向量存储)
""" """
import os import os
@ -10,10 +11,22 @@ from flask import Blueprint, request, jsonify
from sqlalchemy import text from sqlalchemy import text
from app.extensions import db from app.extensions import db
from app.utils.ai_vision import load_clip_model, get_image_embedding from app.utils.ai_vision import load_clip_model, get_image_embedding
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
# 注册蓝图 # 注册蓝图
image_search_bp = Blueprint('image_search', __name__) image_search_bp = Blueprint('image_search', __name__)
# ============================================================================
# 可配置参数
# ============================================================================
# 以图搜图相似度阈值:余弦距离必须小于此值(距离越小越相似)
# 即余弦相似度 = 1 - 距离,必须 > (1 - SIMILARITY_THRESHOLD)
# 默认 0.25 对应余弦相似度 > 0.75
SIMILARITY_DISTANCE_THRESHOLD = 0.40
# ============================================================================ # ============================================================================
# POST /api/v1/common/image-search # POST /api/v1/common/image-search
@ -71,88 +84,211 @@ def image_search():
print(f"⚠️ [ImageSearch] 临时文件删除失败: {e}") print(f"⚠️ [ImageSearch] 临时文件删除失败: {e}")
# --------------------------------------------------------- # ---------------------------------------------------------
# 5. pgvector 余弦相似度检索(跨表联合检索 # 5. pgvector 余弦相似度检索(统一查 image_embeddings 表
# --------------------------------------------------------- # ---------------------------------------------------------
try: try:
query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']' query_vector_str = '[' + ','.join(str(v) for v in embedding) + ']'
sql = text(""" sql = text("""
SELECT id, name, spec_model, image_url, SELECT
(1 - (vec <=> :query_vector)) AS similarity ie.id AS embedding_id,
FROM ( ie.module_name,
-- 1. 基础物料表 ie.target_id,
SELECT id, name, spec_model, product_image AS image_url, img_embedding AS vec ie.image_url,
FROM material_base (1 - (ie.embedding <=> :query_vector)) AS similarity,
WHERE img_embedding IS NOT NULL (ie.embedding <=> :query_vector) AS distance
FROM image_embeddings ie
UNION ALL WHERE ie.embedding IS NOT NULL
AND (ie.embedding <=> :query_vector) < :distance_threshold
-- 2. 采购入库表 (通过 base_id 关联拿真实物料信息) ORDER BY ie.embedding <=> :query_vector
SELECT mb.id, mb.name, mb.spec_model, sb.arrival_photo AS image_url, sb.arrival_image_embedding AS vec LIMIT 200
FROM stock_buy sb
JOIN material_base mb ON sb.base_id = mb.id
WHERE sb.arrival_image_embedding IS NOT NULL
UNION ALL
-- 3. 半成品入库表 (通过 base_id 关联拿真实物料信息)
SELECT mb.id, mb.name, mb.spec_model, ss.arrival_photo AS image_url, ss.arrival_image_embedding AS vec
FROM stock_semi ss
JOIN material_base mb ON ss.base_id = mb.id
WHERE ss.arrival_image_embedding IS NOT NULL
UNION ALL
-- 4. 成品入库表 (通过 base_id 关联拿真实物料信息)
SELECT mb.id, mb.name, mb.spec_model, sp.product_photo AS image_url, sp.arrival_image_embedding AS vec
FROM stock_product sp
JOIN material_base mb ON sp.base_id = mb.id
WHERE sp.arrival_image_embedding IS NOT NULL
) AS combined
-- 核心:计算余弦距离并排序,取最接近的前 50 个!
ORDER BY vec <=> :query_vector LIMIT 50
""") """)
# 执行查询 raw_records = db.session.execute(sql, {
records = db.session.execute(sql, {"query_vector": query_vector_str}).fetchall() "query_vector": query_vector_str,
"distance_threshold": SIMILARITY_DISTANCE_THRESHOLD
}).fetchall()
if not raw_records:
return jsonify({"code": 200, "data": [], "msg": "未找到相似图片(阈值过滤后)"})
results = [] # ---------------------------------------------------------
seen_product_ids = set() # 【新增】用来记录已经添加过的物料 ID # Step 1: 初步去重(同入库单只保留最相似的图片)
# ---------------------------------------------------------
first_img_seen = {}
unique_records = []
for row in raw_records:
key = (row.module_name, row.target_id)
if key not in first_img_seen:
first_img_seen[key] = True
unique_records.append(row)
for row in records: # ---------------------------------------------------------
# 【新增】如果这个物料已经在这个列表里了,直接跳过它 # Step 2: 按物料维度去重(相同物料只保留第一条 = 相似度最高的那条)
if row.id in seen_product_ids: # ---------------------------------------------------------
target_ids_by_module = {}
for row in unique_records:
target_ids_by_module.setdefault(row.module_name, []).append(row.target_id)
# 查询每条记录的 base_id跨 stock_buy/semi/product/material_base
base_id_map = {}
for module in ('stock_buy', 'stock_semi', 'stock_product'):
if module not in target_ids_by_module:
continue continue
ids = target_ids_by_module[module]
ModelCls = StockBuy if module == 'stock_buy' else (StockSemi if module == 'stock_semi' else StockProduct)
id_col = getattr(ModelCls, 'id')
base_col = getattr(ModelCls, 'base_id')
# 记录这个物料 ID保证下次不会再重复添加 rows = (
seen_product_ids.add(row.id) db.session.query(id_col, base_col)
.outerjoin(MaterialBase, base_col == MaterialBase.id)
.filter(id_col.in_(ids))
.all()
)
for rec_id, base_id in rows:
base_id_map[(module, rec_id)] = base_id
# 1. 提取原始 URL if 'material_base' in target_ids_by_module:
raw_url = row.image_url for rec_id in target_ids_by_module['material_base']:
clean_url = "" base_id_map[('material_base', rec_id)] = rec_id
if raw_url: # 按 base_id 去重:相同物料只保留第一张图
if raw_url.startswith('[') and raw_url.endswith(']'): material_seen = {}
import json final_records = []
try: for row in unique_records:
url_list = json.loads(raw_url) base_id = base_id_map.get((row.module_name, row.target_id))
clean_url = url_list[0] if url_list else "" if base_id is not None and base_id in material_seen:
except: continue
clean_url = raw_url if base_id is not None:
else: material_seen[base_id] = True
clean_url = raw_url final_records.append(row)
# 2. 组装返回结果 # ---------------------------------------------------------
# Step 3: 批量回填业务数据(基于去重后的 final_records
# ---------------------------------------------------------
target_ids_by_module = {}
for row in final_records:
target_ids_by_module.setdefault(row.module_name, []).append(row.target_id)
business_map = {}
# 回填 StockBuy
if 'stock_buy' in target_ids_by_module:
ids = target_ids_by_module['stock_buy']
records = (
db.session.query(StockBuy)
.filter(StockBuy.id.in_(ids))
.outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_buy', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'module_name': 'stock_buy',
'url': '/inventory/buy',
}
# 回填 StockSemi
if 'stock_semi' in target_ids_by_module:
ids = target_ids_by_module['stock_semi']
records = (
db.session.query(StockSemi)
.filter(StockSemi.id.in_(ids))
.outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_semi', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'module_name': 'stock_semi',
'url': '/inventory/semi',
}
# 回填 StockProduct
if 'stock_product' in target_ids_by_module:
ids = target_ids_by_module['stock_product']
records = (
db.session.query(StockProduct)
.filter(StockProduct.id.in_(ids))
.outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
.all()
)
for r in records:
business_map[('stock_product', r.id)] = {
'record_id': r.id,
'name': r.base.name if r.base else None,
'spec_model': r.base.spec_model if r.base else None,
'sku': r.sku,
'barcode': r.barcode,
'serial_number': r.serial_number,
'batch_number': r.batch_number,
'status': r.status,
'warehouse_location': r.warehouse_location,
'stock_quantity': r.stock_quantity,
'sale_price': r.sale_price,
'module_name': 'stock_product',
'url': '/inventory/product',
}
# 回填 MaterialBase
if 'material_base' in target_ids_by_module:
ids = target_ids_by_module['material_base']
records = MaterialBase.query.filter(MaterialBase.id.in_(ids)).all()
for r in records:
business_map[('material_base', r.id)] = {
'record_id': r.id,
'name': r.name,
'spec_model': r.spec_model,
'common_name': r.common_name,
'category': r.category,
'material_type': r.material_type,
'unit': r.unit,
'module_name': 'material_base',
'url': '/material/index',
}
# 组装最终返回(基于 final_records按相似度从高到低
results = []
for row in final_records:
key = (row.module_name, row.target_id)
biz = business_map.get(key, {})
raw_url = row.image_url or ''
clean_url = raw_url
if raw_url.startswith('['):
try:
url_list = json.loads(raw_url)
clean_url = url_list[0] if url_list else ''
except:
pass
results.append({ results.append({
"product_id": row.id, "module_name": row.module_name,
"product_name": row.name, "target_id": row.target_id,
"spec_model": row.spec_model,
"image_url": clean_url, "image_url": clean_url,
"similarity": round(float(row.similarity), 4) "similarity": round(float(row.similarity), 4),
"product_name": biz.get('name') or biz.get('material_name') or '未命名物料',
"product_id": row.target_id,
"spec_model": biz.get('spec_model') or '',
"business_data": biz,
}) })
# 修改后:只要凑够了 10 个完全不同的物料,就立刻结束循环
if len(results) >= 10: if len(results) >= 10:
break break

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

@ -142,6 +142,7 @@ def before_update_listener(mapper, connection, target):
changes = {} changes = {}
for attr in state.attrs: for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
if attr.history.has_changes(): if attr.history.has_changes():
old_val = attr.history.deleted[0] if attr.history.deleted else None old_val = attr.history.deleted[0] if attr.history.deleted else None
new_val = attr.history.added[0] if attr.history.added else None new_val = attr.history.added[0] if attr.history.added else None
@ -164,6 +165,8 @@ def before_delete_listener(mapper, connection, target):
state = inspect(target) state = inspect(target)
snap = {} snap = {}
for attr in state.attrs: for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
val = getattr(target, attr.key, None) val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val) snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap}) _create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap})
@ -180,6 +183,8 @@ def after_insert_listener(mapper, connection, target):
state = inspect(target) state = inspect(target)
snap = {} snap = {}
for attr in state.attrs: for attr in state.attrs:
if attr.key in IGNORE_FIELDS: continue
if 'embedding' in attr.key: continue
val = getattr(target, attr.key, None) val = getattr(target, attr.key, None)
snap[attr.key] = _serialize_value(val) snap[attr.key] = _serialize_value(val)
_create_audit_log(connection, mapper, target, 'insert', {'created': snap}) _create_audit_log(connection, mapper, target, 'insert', {'created': snap})

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

@ -2,6 +2,8 @@ from app.extensions import db
from app.models.bom import BomTable from app.models.bom import BomTable
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy import func, distinct, or_, case from sqlalchemy import func, distinct, or_, case
from collections import defaultdict from collections import defaultdict
import uuid import uuid
@ -429,27 +431,31 @@ 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'):
if not bom_no: try:
existing = BomTable.query.filter_by(parent_id=parent_id).first() if not bom_no:
bom_no = existing.bom_no if existing else BomService.generate_bom_no() existing = BomTable.query.filter_by(parent_id=parent_id).first()
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
# 改为对象级删除以触发审计事件 # 改为对象级删除以触发审计事件
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all() old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
for rec in old_records: for rec in old_records:
db.session.delete(rec) db.session.delete(rec)
for item in child_list: for item in child_list:
bom = BomTable( bom = BomTable(
bom_no=bom_no, version=version, parent_id=parent_id, bom_no=bom_no, version=version, parent_id=parent_id,
child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '') child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '')
) )
db.session.add(bom) db.session.add(bom)
db.session.commit() db.session.commit()
# ===== 写入后立刻清除缓存Cache Invalidation =====
_cache_delete(bom_no, version)
logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}")
# ===== 写入后立刻清除缓存Cache Invalidation =====
_cache_delete(bom_no, 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
@ -457,4 +463,69 @@ class BomService:
bom_no = BomService.get_bom_no_by_parent(parent_id) bom_no = BomService.get_bom_no_by_parent(parent_id)
if not bom_no: return [] if not bom_no: return []
detail = BomService.get_bom_with_stock_by_bom_no(bom_no) detail = BomService.get_bom_with_stock_by_bom_no(bom_no)
return detail['children'] if detail else [] return detail['children'] if detail else []
@staticmethod
def calculate_cascade_inventory(bom_no, order_qty):
"""
根据 bom_no 和订单数量,计算所有子件的级联库存缺口。
返回结构供 AI 消费每个子件包含parent_name / spec / name / level_type /
need_qty / available_stock / suggested_qty / gap
若 BOM 不存在返回 None。
"""
# 1. 获取 BOM 明细
detail = BomService.get_bom_detail(bom_no)
if not detail or not detail.get('children'):
return None
parent_name = detail.get('parent_name', '')
# 2. 提取所有子件 ID查询采购库存stock_buy
child_ids = [child['child_id'] for child in detail['children']]
buy_stats = db.session.query(
StockBuy.base_id,
func.coalesce(func.sum(StockBuy.available_quantity), 0).label('total_qty')
).filter(
StockBuy.base_id.in_(child_ids)
).group_by(StockBuy.base_id).all()
buy_map = {stat.base_id: float(stat.total_qty) for stat in buy_stats}
# 3. 提取所有子件的基础物料信息(名称/规格/类型)
materials = db.session.query(
MaterialBase.id,
MaterialBase.name,
MaterialBase.spec_model
).filter(MaterialBase.id.in_(child_ids)).all()
mat_map = {
m.id: {'name': m.name or '', 'spec': m.spec_model or ''}
for m in materials
}
# 4. 遍历子件,计算每个子件的缺口数据
results = []
for child in detail['children']:
child_id = child['child_id']
dosage = float(child.get('dosage') or 0)
need_qty = dosage * order_qty
available_stock = buy_map.get(child_id, 0)
suggested_qty = max(0.0, min(need_qty, available_stock))
gap = available_stock - need_qty
mat_info = mat_map.get(child_id, {'name': '', 'spec': ''})
results.append({
'parent_name': parent_name,
'spec': mat_info['spec'],
'name': mat_info['name'],
'level_type': 'child',
'need_qty': round(need_qty, 4),
'available_stock': round(available_stock, 4),
'suggested_qty': round(suggested_qty, 4),
'gap': round(gap, 4),
})
return results

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

@ -12,7 +12,9 @@ import traceback
import json import json
import io import io
import datetime import datetime
import numpy as np
from app.utils.ai_vision import extract_and_embed from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
# 需要 pip install openpyxl # 需要 pip install openpyxl
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
@ -250,7 +252,8 @@ class MaterialBaseService:
category = filters.get('category') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
query = query.filter(MaterialBase.category.ilike(category.strip())) # 在末尾拼接 '%' 实现前缀模糊匹配
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
type_val = filters.get('type') type_val = filters.get('type')
if type_val is not None and type_val != '': if type_val is not None and type_val != '':
@ -525,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):
"""新增基础信息""" """新增基础信息"""
@ -556,11 +582,23 @@ class MaterialBaseService:
product_image=json.dumps(data.get('generalImage', [])), product_image=json.dumps(data.get('generalImage', [])),
is_enabled=is_enabled_val is_enabled=is_enabled_val
) )
# 实时提取产品图向量(失败不影响业务)
if new_material.product_image:
new_material.img_embedding = extract_and_embed(new_material.product_image)
db.session.add(new_material) db.session.add(new_material)
db.session.flush() # 获取 new_material.id
# 先提交主事务,图片向量异步后台提取
db.session.commit() db.session.commit()
image_list = data.get('generalImage', [])
if isinstance(image_list, list) and image_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_MATERIAL_BASE,
new_material.id,
image_list
)
return new_material return new_material
except Exception as e: except Exception as e:
@ -588,11 +626,24 @@ class MaterialBaseService:
if 'generalManual' in data: if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual']) material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data: if 'generalImage' in data:
material.product_image = json.dumps(data['generalImage']) new_photo_list = data['generalImage']
material.product_image = json.dumps(new_photo_list)
# 补上这两行:提取新上传图片的向量! # 立即触发异步向量提取,不阻塞主事务提交
if material.product_image: if isinstance(new_photo_list, list) and new_photo_list:
material.img_embedding = extract_and_embed(material.product_image) from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_MATERIAL_BASE,
material.id,
new_photo_list
)
else:
material.product_image = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, material.id
)
# 【核心修改】:兼容前端传来的布尔值 # 【核心修改】:兼容前端传来的布尔值
if 'isEnabled' in data: if 'isEnabled' in data:
@ -659,6 +710,10 @@ class MaterialBaseService:
f"请先清理相关库存或仅‘禁用’此条目。" f"请先清理相关库存或仅‘禁用’此条目。"
) )
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_MATERIAL_BASE, material.id
)
db.session.delete(material) db.session.delete(material)
db.session.commit() db.session.commit()
return material_name return material_name
@ -719,7 +774,8 @@ class MaterialBaseService:
category = filters.get('category') category = filters.get('category')
if category is not None and category != '': if category is not None and category != '':
filter_conditions.append(MaterialBase.category.ilike(category.strip())) # 同样在末尾拼接 '%'
filter_conditions.append(MaterialBase.category.ilike(f"{category.strip()}%"))
type_val = filters.get('type') type_val = filters.get('type')
if type_val is not None and type_val != '': if type_val is not None and type_val != '':
filter_conditions.append(MaterialBase.material_type.ilike(type_val.strip())) filter_conditions.append(MaterialBase.material_type.ilike(type_val.strip()))

View File

@ -9,7 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class BuyInboundService: class BuyInboundService:
@ -98,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}】,【半成品】和【成品】不允许直接采购入库!")
# ============================================================ # ============================================================
# 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告 # 强制质检校验:如果物料标记为强制质检,则必须提供到检状态和检测报告
# ============================================================ # ============================================================
@ -178,11 +186,23 @@ class BuyInboundService:
arrival_photo=json.dumps(data.get('arrival_photo', [])), arrival_photo=json.dumps(data.get('arrival_photo', [])),
inspection_report=json.dumps(data.get('inspection_report', [])) inspection_report=json.dumps(data.get('inspection_report', []))
) )
# 实时提取到货图片向量(失败不影响业务)
if new_stock.arrival_photo:
new_stock.arrival_image_embedding = extract_and_embed(new_stock.arrival_photo)
db.session.add(new_stock) db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 先提交主事务(入库单必须落盘),图片向量异步后台提取
db.session.commit() db.session.commit()
photo_list = data.get('arrival_photo', [])
if isinstance(photo_list, list) and photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_BUY,
new_stock.id,
photo_list
)
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -244,7 +264,26 @@ class BuyInboundService:
for k, v in field_mapping.items(): for k, v in field_mapping.items():
if k in data: setattr(stock, v, data[k]) if k in data: setattr(stock, v, data[k])
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo']) if 'arrival_photo' in data:
new_photo_list = data['arrival_photo']
stock.arrival_photo = json.dumps(new_photo_list)
# 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_BUY,
stock.id,
new_photo_list
)
else:
stock.arrival_photo = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report']) if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report'])
# 更新税率 # 更新税率
@ -287,8 +326,11 @@ class BuyInboundService:
try: try:
stock = StockBuy.query.get(stock_id) stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock: raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料' material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_BUY, stock.id
)
db.session.delete(stock) db.session.delete(stock)
db.session.commit() db.session.commit()
return material_name return material_name
@ -346,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

@ -9,7 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class ProductInboundService: class ProductInboundService:
@ -64,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(
@ -77,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(
@ -110,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')
) )
@ -184,11 +195,22 @@ class ProductInboundService:
sale_price=float(data.get('sale_price') or 0), sale_price=float(data.get('sale_price') or 0),
order_id=data.get('order_id') order_id=data.get('order_id')
) )
# 实时提取成品实拍图向量(失败不影响业务)
if new_stock.product_photo:
new_stock.arrival_image_embedding = extract_and_embed(new_stock.product_photo)
db.session.add(new_stock) db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 先提交主事务,图片向量异步后台提取
db.session.commit() db.session.commit()
if isinstance(photo_list, list) and photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_PRODUCT,
new_stock.id,
photo_list
)
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -217,8 +239,24 @@ class ProductInboundService:
if f in data: setattr(stock, f, data[f]) if f in data: setattr(stock, f, data[f])
if 'product_photo' in data: if 'product_photo' in data:
imgs = data['product_photo'] new_photo_list = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs) stock.product_photo = json.dumps(new_photo_list)
# 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_PRODUCT,
stock.id,
new_photo_list
)
else:
stock.product_photo = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, stock.id
)
if 'quality_report_link' in data: if 'quality_report_link' in data:
imgs = data['quality_report_link'] imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs) if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
@ -259,8 +297,11 @@ class ProductInboundService:
try: try:
stock = StockProduct.query.get(stock_id) stock = StockProduct.query.get(stock_id)
if stock: if stock:
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料' material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_PRODUCT, stock.id
)
db.session.delete(stock) db.session.delete(stock)
db.session.commit() db.session.commit()
return material_name return material_name
@ -317,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

@ -9,7 +9,9 @@ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
import traceback import traceback
import json import json
import numpy as np
from app.utils.ai_vision import extract_and_embed from app.utils.ai_vision import extract_and_embed
from app.services.image_embedding_service import ImageEmbeddingService
class SemiInboundService: class SemiInboundService:
@ -69,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(
@ -82,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(
@ -117,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'),
@ -221,11 +232,22 @@ class SemiInboundService:
detail_link=data.get('detail_link'), detail_link=data.get('detail_link'),
remark=data.get('remark') remark=data.get('remark')
) )
# 实时提取到货图片向量(失败不影响业务)
if new_stock.arrival_photo:
new_stock.arrival_image_embedding = extract_and_embed(new_stock.arrival_photo)
db.session.add(new_stock) db.session.add(new_stock)
db.session.flush() # 获取 new_stock.id
# 先提交主事务,图片向量异步后台提取
db.session.commit() db.session.commit()
if isinstance(arrival_list, list) and arrival_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_SEMI,
new_stock.id,
arrival_list
)
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -272,9 +294,24 @@ class SemiInboundService:
setattr(stock, db_attr, data[frontend_key]) setattr(stock, db_attr, data[frontend_key])
if 'arrival_photo' in data: if 'arrival_photo' in data:
imgs = data['arrival_photo'] new_photo_list = data['arrival_photo']
if isinstance(imgs, list): stock.arrival_photo = json.dumps(new_photo_list)
stock.arrival_photo = json.dumps(imgs) # 立即触发异步向量提取,不阻塞主事务提交
if isinstance(new_photo_list, list) and new_photo_list:
from flask import current_app
from app.utils.executor import run_embedding_task
run_embedding_task(
ImageEmbeddingService.save_embeddings_background,
current_app._get_current_object(),
ImageEmbeddingService.MODULE_STOCK_SEMI,
stock.id,
new_photo_list
)
else:
stock.arrival_photo = None
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, stock.id
)
if 'quality_report_link' in data: if 'quality_report_link' in data:
imgs = data['quality_report_link'] imgs = data['quality_report_link']
if isinstance(imgs, list): if isinstance(imgs, list):
@ -348,8 +385,11 @@ class SemiInboundService:
stock = StockSemi.query.get(stock_id) stock = StockSemi.query.get(stock_id)
if not stock: if not stock:
raise ValueError("记录不存在") raise ValueError("记录不存在")
# 提前获取物料名称用于审计日志(通过外键关系 base.name 获取)
material_name = stock.base.name if stock.base else '未知物料' material_name = stock.base.name if stock.base else '未知物料'
# 删除时同步清理向量记录
ImageEmbeddingService.delete_embeddings(
ImageEmbeddingService.MODULE_STOCK_SEMI, stock.id
)
db.session.delete(stock) db.session.delete(stock)
db.session.commit() db.session.commit()
return material_name return material_name
@ -408,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,

Binary file not shown.

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,174 +325,343 @@ 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
# 如果有关键词,需要联表搜索物料名称和规格型号 实现思路(三步走):
if keyword: 步骤 1: 构造 GROUP BY borrow_no 的"单号维度视图" subquery
# 根据 search_type 构建不同的搜索条件 (包含 borrow_no + sort_key + 状态聚合,全部聚合都在这里完成)
if search_type == 'all': 步骤 2: 用一个【纯净的列查询】从 subquery 中分页得到 page_borrow_nos
# 原有逻辑or_ 联表全局模糊搜索 → SELECT 只有 borrow_no 一列,【主查询无 GROUP BY】
# 查询 stock_buy 路径匹配的名称/规格 → 避免触发 PG "column must appear in GROUP BY" 严格模式
buy_match = db.session.query(TransBorrow.id).join( 步骤 3: 用 page_borrow_nos 拉明细 + 预加载 material_name
StockBuy, and_(
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
)
).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 查询 stock_semi 路径匹配的名称/规格 状态过滤按"单号聚合"判定:
semi_match = db.session.query(TransBorrow.id).join( - borrowed: 单号下至少有一条 is_returned=False
StockSemi, and_( - returned: 单号下所有明细 is_returned=True
TransBorrow.stock_id == StockSemi.id, """
TransBorrow.source_table == 'stock_semi' try:
) # ====================================================================
).join( # 步骤 1a构造"单号维度"基础子查询GROUP BY borrow_no 在这里完成)
MaterialBase, StockSemi.base_id == MaterialBase.id # ====================================================================
).filter( # 单号 + 排序键(最早 expected_return_time—— 这一层只含 2 列 + GROUP BY
or_( order_subq = (
MaterialBase.name.ilike(f'%{keyword}%'), db.session.query(
MaterialBase.spec_model.ilike(f'%{keyword}%') TransBorrow.borrow_no.label('borrow_no'),
) func.min(TransBorrow.expected_return_time).label('sort_key')
).subquery() )
.group_by(TransBorrow.borrow_no)
.subquery()
)
# 查询 stock_product 路径匹配的名称/规格 # 状态聚合子查询(也是 GROUP BY borrow_no
product_match = db.session.query(TransBorrow.id).join( status_subq = (
StockProduct, and_( db.session.query(
TransBorrow.stock_id == StockProduct.id, TransBorrow.borrow_no.label('borrow_no'),
TransBorrow.source_table == 'stock_product' func.sum(
) case((TransBorrow.is_returned == False, 1), else_=0)
).join( ).label('unreturned_count')
MaterialBase, StockProduct.base_id == MaterialBase.id )
).filter( .group_by(TransBorrow.borrow_no)
or_( .subquery()
MaterialBase.name.ilike(f'%{keyword}%'), )
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 合并三种来源的匹配 ID # ====================================================================
all_matches = db.session.query(buy_match.c.id).union( # 步骤 1b构造关键词命中单号子查询保留原全部 search_type 逻辑)
db.session.query(semi_match.c.id), # ====================================================================
db.session.query(product_match.c.id) keyword_conditions = None
).subquery() if keyword:
# 根据 search_type 构建不同的搜索条件
if search_type == 'all':
# 原有逻辑or_ 联表全局模糊搜索
# 查询 stock_buy 路径匹配的名称/规格
buy_match = db.session.query(TransBorrow.id).join(
StockBuy, and_(
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
)
).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
keyword_conditions = or_( # 查询 stock_semi 路径匹配的名称/规格
TransBorrow.borrower_name.ilike(f'%{keyword}%'), semi_match = db.session.query(TransBorrow.id).join(
TransBorrow.sku.ilike(f'%{keyword}%'), StockSemi, and_(
TransBorrow.borrow_no.ilike(f'%{keyword}%'), TransBorrow.stock_id == StockSemi.id,
TransBorrow.id.in_(all_matches) TransBorrow.source_table == 'stock_semi'
)
).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 查询 stock_product 路径匹配的名称/规格
product_match = db.session.query(TransBorrow.id).join(
StockProduct, and_(
TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product'
)
).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 合并三种来源的匹配 ID
all_matches = db.session.query(buy_match.c.id).union(
db.session.query(semi_match.c.id),
db.session.query(product_match.c.id)
).subquery()
keyword_conditions = or_(
TransBorrow.borrower_name.ilike(f'%{keyword}%'),
TransBorrow.sku.ilike(f'%{keyword}%'),
TransBorrow.borrow_no.ilike(f'%{keyword}%'),
TransBorrow.id.in_(all_matches)
)
elif search_type == 'no':
keyword_conditions = TransBorrow.borrow_no.ilike(f'%{keyword}%')
elif search_type == 'name':
keyword_conditions = TransBorrow.borrower_name.ilike(f'%{keyword}%')
elif search_type == 'sku':
keyword_conditions = TransBorrow.sku.ilike(f'%{keyword}%')
elif search_type == 'material_name':
# 联表查询物料名称
buy_match = db.session.query(TransBorrow.id).join(
StockBuy, and_(
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
)
).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
semi_match = db.session.query(TransBorrow.id).join(
StockSemi, and_(
TransBorrow.stock_id == StockSemi.id,
TransBorrow.source_table == 'stock_semi'
)
).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
product_match = db.session.query(TransBorrow.id).join(
StockProduct, and_(
TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product'
)
).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
all_matches = db.session.query(buy_match.c.id).union(
db.session.query(semi_match.c.id),
db.session.query(product_match.c.id)
).subquery()
keyword_conditions = TransBorrow.id.in_(all_matches)
elif search_type == 'spec_model':
# 联表查询规格型号
buy_match = db.session.query(TransBorrow.id).join(
StockBuy, and_(
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
)
).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
semi_match = db.session.query(TransBorrow.id).join(
StockSemi, and_(
TransBorrow.stock_id == StockSemi.id,
TransBorrow.source_table == 'stock_semi'
)
).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
product_match = db.session.query(TransBorrow.id).join(
StockProduct, and_(
TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product'
)
).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
all_matches = db.session.query(buy_match.c.id).union(
db.session.query(semi_match.c.id),
db.session.query(product_match.c.id)
).subquery()
keyword_conditions = TransBorrow.id.in_(all_matches)
# 把"命中的单号"独立成 subquery供主查询做 IN 过滤
keyword_borrow_nos_subq = None
if keyword_conditions is not None:
keyword_borrow_nos_subq = (
db.session.query(TransBorrow.borrow_no)
.filter(keyword_conditions)
.distinct()
.subquery()
) )
elif search_type == 'no': # ====================================================================
keyword_conditions = TransBorrow.borrow_no.ilike(f'%{keyword}%') # 步骤 2纯净列查询分页SELECT 只有 order_subq.c.borrow_no 一列)
# ====================================================================
borrow_no_q = db.session.query(order_subq.c.borrow_no)
elif search_type == 'name': # 关键词过滤
keyword_conditions = TransBorrow.borrower_name.ilike(f'%{keyword}%') 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)
)
elif search_type == 'sku': # 状态过滤(按"单号聚合"判定)
keyword_conditions = TransBorrow.sku.ilike(f'%{keyword}%') if status == 'borrowed':
# 单号下至少一条未还
elif search_type == 'material_name': borrow_no_q = borrow_no_q.filter(
# 联表查询物料名称 order_subq.c.borrow_no.in_(
buy_match = db.session.query(TransBorrow.id).join( db.session.query(status_subq.c.borrow_no)
StockBuy, and_( .filter(status_subq.c.unreturned_count > 0)
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
) )
).join( )
MaterialBase, StockBuy.base_id == MaterialBase.id elif status == 'returned':
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() # 单号下所有明细都已归还
borrow_no_q = borrow_no_q.filter(
semi_match = db.session.query(TransBorrow.id).join( order_subq.c.borrow_no.in_(
StockSemi, and_( db.session.query(status_subq.c.borrow_no)
TransBorrow.stock_id == StockSemi.id, .filter(status_subq.c.unreturned_count == 0)
TransBorrow.source_table == 'stock_semi'
) )
).join( )
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
product_match = db.session.query(TransBorrow.id).join( # 排序(单号维度的 sort_key ASC
StockProduct, and_( borrow_no_q = borrow_no_q.order_by(nullslast(asc(order_subq.c.sort_key)))
TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product'
)
).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
all_matches = db.session.query(buy_match.c.id).union( # 分页(基准 = borrow_no 单号数)
db.session.query(semi_match.c.id), pagination = borrow_no_q.paginate(page=page, per_page=limit, error_out=False)
db.session.query(product_match.c.id) # ★ pagination.items 是 SQLAlchemy Row 对象psycopg2 无法直接 adapt Row
).subquery() # 用 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 # ★ 单号总数(修复前是明细数,分页错乱根因)
keyword_conditions = TransBorrow.id.in_(all_matches) if not page_borrow_nos:
return {
'items': [],
'total': total_orders,
'page': page,
'limit': limit
}
elif search_type == 'spec_model': # ====================================================================
# 联表查询规格型号 # 步骤 3按当前页 borrow_no 集合一次性拉出所有明细
buy_match = db.session.query(TransBorrow.id).join( # ====================================================================
StockBuy, and_( detail_records = (
TransBorrow.stock_id == StockBuy.id, TransBorrow.query
TransBorrow.source_table == 'stock_buy' .filter(TransBorrow.borrow_no.in_(page_borrow_nos))
) .order_by(TransBorrow.borrow_no.asc(), TransBorrow.id.asc())
).join( .all()
MaterialBase, StockBuy.base_id == MaterialBase.id )
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
semi_match = db.session.query(TransBorrow.id).join( # ============================================================
StockSemi, and_( # ★ 批量预加载物料名称三步收集ID → 批量JOIN → SKU兜底
TransBorrow.stock_id == StockSemi.id, # ============================================================
TransBorrow.source_table == 'stock_semi' items_with_names = []
) items = detail_records
).join( if items:
MaterialBase, StockSemi.base_id == MaterialBase.id # 步骤 1收集所有 (source_table, stock_id) 对
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() 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)
product_match = db.session.query(TransBorrow.id).join( # 步骤 2批量查询库存表并 JOIN MaterialBase
StockProduct, and_( stock_map = {} # { ('stock_buy', 101): '物料名称', ... }
TransBorrow.stock_id == StockProduct.id, model_map = {
TransBorrow.source_table == 'stock_product' 'stock_buy': StockBuy,
) 'stock_semi': StockSemi,
).join( 'stock_product': StockProduct
MaterialBase, StockProduct.base_id == MaterialBase.id }
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() 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
all_matches = db.session.query(buy_match.c.id).union( # 步骤 3前置收集 SKU 兜底候选集
db.session.query(semi_match.c.id), empty_sku_set = set()
db.session.query(product_match.c.id) for item in items:
).subquery() name = stock_map.get((item.source_table, item.stock_id), '')
if not name and item.sku:
empty_sku_set.add(item.sku)
keyword_conditions = TransBorrow.id.in_(all_matches) # 步骤 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
else: # 步骤 3为每条记录注入 material_name含 SKU 兜底)
keyword_conditions = None for item in items:
else: item_dict = item.to_dict()
keyword_conditions = None 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)
if keyword_conditions is not None: return {
q = q.filter(keyword_conditions) 'items': items_with_names,
'total': total_orders,
if status == 'borrowed': 'page': page,
q = q.filter(TransBorrow.is_returned == False) 'limit': limit
elif status == 'returned': }
q = q.filter(TransBorrow.is_returned == True) except Exception as e:
# ★ 捕鼠器:把任何 SQL/运行时错误以 500 + traceback 返回,避免静默吞噬
# 使用 distinct 防止跨表查询产生重复记录 import traceback
q = q.distinct() return {
'code': 500,
q = q.order_by(nullslast(asc(TransBorrow.expected_return_time))) 'msg': str(e),
pagination = q.paginate(page=page, per_page=limit, error_out=False) 'trace': traceback.format_exc(),
'items': [],
return { 'total': 0,
'items': [r.to_dict() for r in pagination.items], 'page': page,
'total': pagination.total, 'limit': limit
'page': page, }
'limit': limit
}

View File

@ -9,6 +9,7 @@ import time
import numpy as np import numpy as np
from PIL import Image from PIL import Image
import onnxruntime as ort import onnxruntime as ort
import cv2
# ============================================================================ # ============================================================================
# 全局模型单例(项目启动时加载一次) # 全局模型单例(项目启动时加载一次)
@ -48,33 +49,115 @@ IMAGENET_STD = [0.229, 0.224, 0.225]
# 模型输入尺寸 # 模型输入尺寸
INPUT_SIZE = 224 INPUT_SIZE = 224
# ============================================================================
# 背景去除配置HSV 色彩空间阈值
# ============================================================================
# OpenCV HSV: H∈[0,180], S∈[0,255], V∈[0,255]
# 注意OpenCV 中 H 通道范围是 0-180是 OpenCV 自己的标准,和美术的 0-360 对应)
def _center_crop_and_resize(image: Image.Image) -> Image.Image: # 绿色背景阈值(工业绿幕常用色)
# H: 35~85 对应绿色谱(浅绿到深绿)
# S: 低饱和度35到高饱和度255
# V: 明暗均可30~255
BG_GREEN_LOWER = np.array([35, 35, 30])
BG_GREEN_UPPER = np.array([90, 255, 255])
# 白色/浅色背景阈值(高明度、低饱和度区域)
# H: 不限制0~180只看 S 和 V
# S: 很低的饱和度0~35→ 接近纯灰/白色
# V: 高明度180~255
BG_WHITE_LOWER = np.array([0, 0, 180])
BG_WHITE_UPPER = np.array([180, 40, 255])
# 中性灰填充色BGR → 转换后 RGB 也是 128,128,128
NEUTRAL_GRAY_BGR = (128, 128, 128)
def _remove_background(image: Image.Image) -> Image.Image:
""" """
CLIP 官方预处理:中心裁剪抗干扰 利用 OpenCV HSV 色彩空间识别并替换背景为中性灰
- 将图片最短边缩放到 224
- 从正中间切取 224x224 区域 支持两种背景类型:
1. 工业绿幕/绿色背景H: 35~90
2. 白色/浅色背景(高亮度、低饱和度)
逻辑:
- 将 PIL Image 转为 OpenCV 格式 (RGB → BGR)
- 转 HSV分别生成绿色掩码和白色掩码
- 合并掩码后,按掩码将背景区域替换为中性灰
- 还原为 PIL Image (BGR → RGB) 返回
参数:
image: PIL Image (RGB, uint8)
返回:
处理后的 PIL Image (RGB, uint8)
"""
# PIL (RGB) → OpenCV (BGR)
img_bgr = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
# 转入 HSV 色彩空间
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
# 生成掩码 1绿色背景
mask_green = cv2.inRange(hsv, BG_GREEN_LOWER, BG_GREEN_UPPER)
# 生成掩码 2白色/浅色背景
mask_white = cv2.inRange(hsv, BG_WHITE_LOWER, BG_WHITE_UPPER)
# 合并掩码(任意一种背景都替换)
mask_combined = cv2.bitwise_or(mask_green, mask_white)
# 形态学处理:消除噪点(小面积背景噪点填平)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask_combined = cv2.morphologyEx(mask_combined, cv2.MORPH_CLOSE, kernel)
mask_combined = cv2.morphologyEx(mask_combined, cv2.MORPH_OPEN, kernel)
# 背景替换:将掩码区域填充为中性灰
# 其中 mask_combined=255 的区域为背景,替换为 NEUTRAL_GRAY_BGR
img_bgr_no_bg = img_bgr.copy()
img_bgr_no_bg[mask_combined > 0] = NEUTRAL_GRAY_BGR
# OpenCV (BGR) → PIL (RGB)
result = Image.fromarray(cv2.cvtColor(img_bgr_no_bg, cv2.COLOR_BGR2RGB))
return result
def _letterbox_image(image: Image.Image, size: int = 224) -> Image.Image:
"""
Letterbox 预处理:等比例缩放 + 灰色填充,保持内容不变形
- 将原图最长边缩放到 224
- 短边按相同比例缩放
- 不足部分用 RGB(128,128,128) 灰色填充至 224x224
参数:
image: PIL Image 对象
size: 目标尺寸,默认 224
返回:
224x224 PIL Image
""" """
w, h = image.size w, h = image.size
# 计算缩放后的目标尺寸 # 计算缩放比例,使最长边等于 size
if w < h: scale = size / max(w, h)
new_w = INPUT_SIZE new_w = int(w * scale)
new_h = int(h * INPUT_SIZE / w) new_h = int(h * scale)
else:
new_h = INPUT_SIZE
new_w = int(w * INPUT_SIZE / h)
# 缩放 # 等比例缩放
image = image.resize((new_w, new_h), Image.BILINEAR) resized = image.resize((new_w, new_h), Image.LANCZOS)
# 中心裁剪 # 创建灰色画布
left = (new_w - INPUT_SIZE) // 2 canvas = Image.new('RGB', (size, size), (128, 128, 128))
top = (new_h - INPUT_SIZE) // 2
right = left + INPUT_SIZE
bottom = top + INPUT_SIZE
return image.crop((left, top, right, bottom)) # 将缩放后的图片粘贴到画布正中央
paste_x = (size - new_w) // 2
paste_y = (size - new_h) // 2
canvas.paste(resized, (paste_x, paste_y))
return canvas
def _normalize(image_np: np.ndarray) -> np.ndarray: def _normalize(image_np: np.ndarray) -> np.ndarray:
@ -111,8 +194,12 @@ def get_image_embedding(image_path: str) -> list:
load_clip_model() load_clip_model()
# 1. 图片预处理 # 1. 图片预处理
# Step 1: 背景去除HSV 色彩空间,绿色/白色背景 → 中性灰替换)
image = Image.open(image_path).convert('RGB') image = Image.open(image_path).convert('RGB')
image = _center_crop_and_resize(image) image = _remove_background(image)
# Step 2: Letterbox 等比例缩放(保持内容不变形)
image = _letterbox_image(image, INPUT_SIZE)
input_data = _normalize(np.array(image)) input_data = _normalize(np.array(image))
input_data = np.expand_dims(input_data, axis=0) # [1, 3, 224, 224] input_data = np.expand_dims(input_data, axis=0) # [1, 3, 224, 224]

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

@ -0,0 +1,25 @@
import atexit
import logging
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger(__name__)
# 全局初始化线程池
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix='image_embedding_')
def run_embedding_task(fn, *args, **kwargs):
"""
提交后台任务到线程池
"""
logger.info("Submitting embedding task to background thread...")
return _executor.submit(fn, *args, **kwargs)
def _shutdown_executor():
"""
优雅关闭线程池,在 Gunicorn worker 退出时触发
"""
logger.info("Shutting down background thread pool...")
_executor.shutdown(wait=False)
# 注册到系统退出事件,这样就不会报 _shutdown not defined 了
atexit.register(_shutdown_executor)

View File

@ -10,6 +10,8 @@ flask-cors==4.0.0
redis==5.0.1 redis==5.0.1
# 图片处理核心库 # 图片处理核心库
Pillow>=10.0.0 Pillow>=10.0.0
# OpenCV背景去除、HSV色彩空间抠图
opencv-python-headless>=4.8.0
# ONNX 模型本地 CPU 推理 # ONNX 模型本地 CPU 推理
onnxruntime>=1.16.0 onnxruntime>=1.16.0
# 数值计算ONNX 推理依赖) # 数值计算ONNX 推理依赖)

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.32识图版 当前版本: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

@ -47,6 +47,27 @@ export interface ImageSearchItem {
spec_model: string spec_model: string
image_url: string image_url: string
similarity: number similarity: number
module_name: string
target_id: number
business_data: {
record_id: number
name?: string
spec_model?: string
sku?: string
barcode?: string
serial_number?: string
batch_number?: string
status?: string
warehouse_location?: string
stock_quantity?: number
sale_price?: number
common_name?: string
category?: string
material_type?: string
unit?: string
module_name: string
url: string
}
} }
/** 以图搜图响应结构 */ /** 以图搜图响应结构 */

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

@ -86,4 +86,12 @@ export function markWarningOrdered(data: { baseId: number; isOrdered: boolean })
method: 'post', method: 'post',
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,14 +105,35 @@
<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 { Camera, Loading, Picture, WarningFilled } from '@element-plus/icons-vue' import { useRouter } from 'vue-router'
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()
interface Props { interface Props {
modelValue: boolean modelValue: boolean
@ -123,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) {
@ -134,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
@ -176,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 = []
@ -185,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;
} }
@ -200,7 +258,15 @@ const handleUse = (item: ImageSearchItem) => {
} }
const handleView = (item: ImageSearchItem) => { const handleView = (item: ImageSearchItem) => {
emit('view', item) const biz = item.business_data
if (biz?.spec_model) {
router.push({ path: '/material/index', query: { keyword: biz.spec_model } })
} else if (biz?.url) {
router.push(biz.url)
} else {
ElMessage.warning('无法定位目标页面')
}
handleClose()
} }
const handleClose = () => { const handleClose = () => {
@ -208,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
@ -223,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>
@ -32,19 +32,16 @@
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
clearable clearable
filterable filterable
allow-create
default-first-option
style="width: 240px; margin-right: 10px;" style="width: 240px; margin-right: 10px;"
@change="handleQuery" @change="handleQuery"
popper-class="long-dropdown" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.type" v-model="queryParams.type"
@ -171,21 +168,31 @@
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" /> <el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
</template> </template>
<div class="column-setting-list"> <div class="column-setting-list">
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px"> <div style="display: flex; justify-content: space-between; align-items: center; font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置 <span>列展示设置</span>
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
</div> </div>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" /> <el-checkbox v-if="hasColPermission('id')" v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.name.visible" label="名称" /> <el-checkbox v-if="hasColPermission('companyName')" v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-model="columns.commonName.visible" label="名" /> <el-checkbox v-if="hasColPermission('name')" v-model="columns.name.visible" label="名" />
<el-checkbox v-model="columns.category.visible" label="类别" /> <el-checkbox v-if="hasColPermission('commonName')" v-model="columns.commonName.visible" label="专业名称" />
<el-checkbox v-model="columns.type.visible" label="类" /> <el-checkbox v-if="hasColPermission('category')" v-model="columns.category.visible" label="类" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" /> <el-checkbox v-if="hasColPermission('type')" v-model="columns.type.visible" label="类型" />
<el-checkbox v-model="columns.unit.visible" label="单位" /> <el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" /> <el-checkbox v-if="hasColPermission('unit')" v-model="columns.unit.visible" label="单位" />
<el-checkbox v-model="columns.available.visible" label="可用数" /> <el-checkbox v-if="hasColPermission('inventory')" v-model="columns.inventory.visible" label="库存数" />
<el-checkbox v-model="columns.files.visible" label="资料" /> <el-checkbox v-if="hasColPermission('available')" v-model="columns.available.visible" label="可用数" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" /> <el-checkbox v-if="hasColPermission('files')" v-model="columns.files.visible" label="资料" />
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
</div> </div>
</el-popover> </el-popover>
</div> </div>
@ -215,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>
@ -252,6 +259,7 @@
:src="getImageUrl(getImagesOnly(row.generalImage)[0])" :src="getImageUrl(getImagesOnly(row.generalImage)[0])"
:preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))" :preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))"
preview-teleported preview-teleported
hide-on-click-modal
fit="cover" fit="cover"
/> />
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span> <span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
@ -270,6 +278,7 @@
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))" :preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
fit="cover" fit="cover"
preview-teleported preview-teleported
hide-on-click-modal
/> />
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span> <span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
</div> </div>
@ -306,7 +315,7 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:view_warning')" label="预警状态" width="120" align="center"> <el-table-column v-if="columns.warningStatus.visible" label="预警状态" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.warningStatus === 2"> <template v-if="row.warningStatus === 2">
<el-tag type="danger" size="small">红色预警</el-tag> <el-tag type="danger" size="small">红色预警</el-tag>
@ -339,7 +348,7 @@
<el-pagination <el-pagination
v-model:current-page="queryParams.pageNum" v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize" v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]" :page-sizes="[50, 100, 200, 500]"
:background="true" :background="true"
layout="total, sizes, prev, pager, next, jumper" layout="total, sizes, prev, pager, next, jumper"
:total="total" :total="total"
@ -354,22 +363,33 @@
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>
<el-link <div style="display: flex; align-items: center; gap: 16px;">
v-if="form.id" <el-link
type="success" v-if="form.id"
:underline="false" type="primary"
style="font-size: 14px;" :underline="false"
@click="createBomForMaterial" style="font-size: 14px;"
> @click="handleSaveAs"
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM >
</el-link> <el-icon style="margin-right: 4px"><DocumentCopy /></el-icon>另存为新项
</el-link>
<el-link
v-if="form.id"
type="success"
:underline="false"
style="font-size: 14px;"
@click="createBomForMaterial"
>
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</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">
@ -381,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>
@ -400,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
@ -421,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>
@ -448,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>
@ -485,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')">
@ -538,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')">
@ -556,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"
@ -575,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"
@ -611,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"
@ -642,8 +671,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed } 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';
@ -660,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';
@ -734,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' },
@ -884,7 +914,8 @@ const columns = reactive({
available: { visible: true }, available: { visible: true },
files: { visible: true }, files: { visible: true },
isEnabled: { visible: true }, isEnabled: { visible: true },
isInspectionRequired: { visible: true } isInspectionRequired: { visible: true },
warningStatus: { visible: true }
}); });
// 列与权限Code的映射关系数据库中的code // 列与权限Code的映射关系数据库中的code
@ -901,22 +932,78 @@ const permissionMap: Record<string, string> = {
available: 'material_list:availableCount', available: 'material_list:availableCount',
files: 'material_list:files', files: 'material_list:files',
isEnabled: 'material_list:isEnabled', isEnabled: 'material_list:isEnabled',
isInspectionRequired: 'material_list:operation' isInspectionRequired: 'material_list:operation',
warningStatus: 'material_list:view_warning'
}; };
// 根据用户权限初始化列显示状态 // ================= 全选与本地缓存逻辑 =================
// 获取唯一缓存 Key (加上用户名,防止同一个浏览器切换账号时设置错乱)
const getStorageKey = () => `MOM_BASIC_INFO_COLS_${userStore.username || 'DEFAULT'}`;
// 辅助方法:判断当前用户是否有某列的权限
const hasColPermission = (key: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
const code = permissionMap[key];
return code ? !!userStore.hasPermission(code) : true;
};
// 计算属性:判断是否"全选"了所有【有权限】的列
const isAllSelected = computed(() => {
const allowedKeys = Object.keys(columns).filter(k => hasColPermission(k));
return allowedKeys.length > 0 && allowedKeys.every(k => columns[k as keyof typeof columns].visible);
});
// 计算属性:判断是否"半选" (Element UI 中 checkbox 的 indeterminate 状态)
const isIndeterminate = computed(() => {
const allowedKeys = Object.keys(columns).filter(k => hasColPermission(k));
const checkedCount = allowedKeys.filter(k => columns[k as keyof typeof columns].visible).length;
return checkedCount > 0 && checkedCount < allowedKeys.length;
});
// 事件:点击"全选"复选框时触发
const handleCheckAllChange = (val: boolean) => {
Object.keys(columns).forEach(key => {
// 只有用户有权限的列,才会被全选/全不选操作控制
if (hasColPermission(key)) {
columns[key as keyof typeof columns].visible = val;
}
});
};
// 监听:只要列展示状态发生变化,就自动保存到浏览器本地
watch(columns, (newVal) => {
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
}, { deep: true });
// ================= 修改:权限初始化与读取缓存 =================
// 修改你原有的 initColumnPermissions 函数
const initColumnPermissions = () => { const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列 // 1. 尝试从本地缓存读取用户上次的设置
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') { const cachedData = localStorage.getItem(getStorageKey());
return; let parsedCache: Record<string, any> | null = null;
if (cachedData) {
try {
parsedCache = JSON.parse(cachedData);
} catch (e) {
console.error('解析列缓存失败', e);
}
} }
// 普通用户:严格执行列级权限控制,没有权限的列必须隐藏 // 2. 遍历列进行权限判断与缓存赋值
Object.keys(columns).forEach(key => { Object.keys(columns).forEach(key => {
const code = permissionMap[key]; const colKey = key as keyof typeof columns;
if (code) { const hasPerm = hasColPermission(colKey);
// 如果不具备该权限,必须设为 false
columns[key].visible = !!userStore.hasPermission(code); if (!hasPerm) {
// 【权限最高】如果没有权限,强制隐藏,无视任何缓存
columns[colKey].visible = false;
} else {
// 如果有权限,且存在本地缓存,则使用本地缓存的值
if (parsedCache && parsedCache[colKey] !== undefined) {
columns[colKey].visible = parsedCache[colKey].visible;
}
} }
}); });
}; };
@ -937,15 +1024,42 @@ 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[]>([]);
// 用于搜索栏级联选择器的数据绑定中转
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
// 类别级联选择器的 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;
categoryCascaderRef.value.togglePopperVisible(false);
// 1) 收起下拉
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);
} }
}; };
@ -954,7 +1068,7 @@ const tempCategorySuffix = ref<string>('');
const queryParams = reactive<QueryParams>({ const queryParams = reactive<QueryParams>({
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 100,
keyword: '', keyword: '',
searchField: 'all', searchField: 'all',
category: '', category: '',
@ -1051,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()))
@ -1245,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: '' });
@ -1598,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))
@ -1736,18 +1891,29 @@ const resetAdvancedFilter = () => {
getList(); getList();
}; };
onMounted(() => { // 以图搜图跳转:监听路由 keyword 参数,自动搜索并清理 URL
// 1. 修复背景联动:直接对 reactive 对象赋值 watch(
if (route.query.keyword) { () => route.query.keyword,
queryParams.keyword = route.query.keyword as string; (newKeyword) => {
queryParams.searchField = 'all'; if (newKeyword) {
} queryParams.keyword = newKeyword as string;
queryParams.searchField = 'all';
getList();
// 清理 URL 参数,防止刷新后重复触发搜索
router.replace({ path: route.path, query: {} });
}
},
{ immediate: true }
);
// 先根据权限初始化列显示状态 onMounted(() => {
initColumnPermissions(); initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据 // 无外部 keyword 参数时执行默认查询;有 keyword 则由上方的 watch 接管
getList(); if (!route.query.keyword) {
getList();
}
getOptionsList(); getOptionsList();
fetchUnitList();
// 2. 修复弹窗锁定逻辑 // 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query); console.log('--- 准备检测外部跳转参数 ---', route.query);

View File

@ -527,12 +527,14 @@ const totalExportCount = computed(() => {
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stockO(N),无嵌套循环)--- // --- BOM 齐套性分析计算属性(使用后端已计算的 current_stockO(N),无嵌套循环)---
const maxBuildableSets = computed(() => { const maxBuildableSets = computed(() => {
if (!currentBomDetail.value?.length) return 0 if (!currentBomDetail.value?.length) return 0
return currentBomDetail.value.reduce((minSets, bomItem: any) => { const result = currentBomDetail.value.reduce((minSets, bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0 const dosage = parseFloat(bomItem.dosage) || 0
if (dosage <= 0) return minSets if (dosage <= 0) return minSets
const stock = parseFloat(bomItem.current_stock) || 0 const stock = parseFloat(bomItem.current_stock) || 0
return Math.min(minSets, Math.floor(stock / dosage)) return Math.min(minSets, Math.floor(stock / dosage))
}, Infinity) }, Infinity)
return result === Infinity ? 0 : result
}) })
const shortageList = computed(() => { const shortageList = computed(() => {
@ -723,8 +725,8 @@ watch(selectedBomNo, async (newBomNo) => {
const confirmBomAdd = async () => { const confirmBomAdd = async () => {
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM') if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
if (allStockData.value.length === 0) { if (stockList.value.length === 0) {
await ensureAllStockLoaded() await loadStockList()
} }
if (currentBomDetail.value.length === 0) { if (currentBomDetail.value.length === 0) {
@ -745,7 +747,7 @@ const confirmBomAdd = async () => {
const dosage = parseFloat(bomItem.dosage) || 0 const dosage = parseFloat(bomItem.dosage) || 0
const needQty = dosage * bomSets.value const needQty = dosage * bomSets.value
const stockCandidate = allStockData.value.find(s => const stockCandidate = stockList.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id) (s.base_id && s.base_id == bomItem.child_id)
) )

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
handleSearchMaterial('') // 防御性拦截:竞态条件守卫
} // 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
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"
@ -111,18 +111,32 @@
<template #reference> <template #reference>
<el-button :icon="Setting" circle class="circle-btn" /> <el-button :icon="Setting" circle class="circle-btn" />
</template> </template>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; margin-bottom: 10px; padding-bottom: 10px;">
<span style="font-weight: bold;">列展示设置</span>
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<div class="col-group-title">基础信息</div> <div class="col-group-title">基础信息</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop"> <template v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox> <el-col :span="12" v-if="hasColumnPermission(c.prop)">
</el-col> <el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</template>
</el-row> </el-row>
<div class="col-group-title" style="margin-top:10px">库存与商务</div> <div class="col-group-title" style="margin-top:10px">库存与商务</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop"> <template v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox> <el-col :span="12" v-if="hasColumnPermission(c.prop)">
</el-col> <el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</template>
</el-row> </el-row>
</el-checkbox-group> </el-checkbox-group>
</el-popover> </el-popover>
@ -193,6 +207,7 @@
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])" :src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))" :preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported preview-teleported
hide-on-click-modal
fit="cover" fit="cover"
lazy lazy
> >
@ -226,11 +241,7 @@
<el-icon><Printer/></el-icon> 打印 <el-icon><Printer/></el-icon> 打印
</el-button> </el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220"> <el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
<template #reference>
<el-button link type="danger" size="default" v-permission="'inbound_buy:delete'">删除</el-button>
</template>
</el-popconfirm>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -253,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"
@ -283,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>
@ -321,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;">
@ -456,7 +454,8 @@
:http-request="(opts) => customUpload(opts, 'arrival_photo')" :http-request="(opts) => customUpload(opts, 'arrival_photo')"
:on-preview="handlePreviewPicture" :on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')"
:before-upload="beforeAvatarUpload"> :before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -475,7 +474,8 @@
:http-request="(opts) => customUpload(opts, 'inspection_report')" :http-request="(opts) => customUpload(opts, 'inspection_report')"
:on-preview="handlePreviewPicture" :on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'inspection_report')" :on-remove="(file) => handleRemoveImage(file, 'inspection_report')"
:before-upload="beforeAvatarUpload"> :before-upload="beforeAvatarUpload"
:before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('inspection_report')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -638,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"
@ -647,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;"/>
@ -806,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,
@ -822,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)
@ -899,6 +907,8 @@ const operatorOptions = ref([
{ value: 'le', label: '小于等于' } { value: 'le', label: '小于等于' }
]) ])
// ================= 第一步:声明基础数据 =================
// 基础列 // 基础列
const baseColumns = [ const baseColumns = [
{prop: 'company_name', label: '所属公司'}, {prop: 'company_name', label: '所属公司'},
@ -939,6 +949,8 @@ const stockColumns = [
{prop: 'inspection_report', label: '检测报告', minWidth: '100'} {prop: 'inspection_report', label: '检测报告', minWidth: '100'}
] ]
const allColumns = [...baseColumns, ...stockColumns]
// 列与权限Code的映射关系数据库中的code // 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = { const permissionMap: Record<string, string> = {
id: 'inbound_buy:id', id: 'inbound_buy:id',
@ -973,14 +985,15 @@ const permissionMap: Record<string, string> = {
inspection_report: 'inbound_buy:inspection_report' inspection_report: 'inbound_buy:inspection_report'
} }
// 初始化列显示状态(纯权限驱动,废除本地缓存) // ================= 第二步:声明响应式变量 =================
const initColumnPermissions = () => { const visibleColumnProps = ref<string[]>([])
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
}
// 检查列权限 // ================= 第三步:按依赖顺序放置方法和监听 =================
// 1. 获取唯一缓存 Key
const getStorageKey = () => `MOM_INBOUND_BUY_COLS_${userStore.username || 'DEFAULT'}`;
// 2. 检查列权限(依赖 permissionMap
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
@ -989,9 +1002,49 @@ const hasColumnPermission = (prop: string) => {
return code ? userStore.hasPermission(code) : false return code ? userStore.hasPermission(code) : false
} }
const allColumns = [...baseColumns, ...stockColumns] // 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey
const initColumnPermissions = () => {
const allowedProps = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop);
const visibleColumnProps = ref<string[]>([]) const cachedData = localStorage.getItem(getStorageKey());
if (cachedData) {
try {
const parsedCache = JSON.parse(cachedData);
visibleColumnProps.value = parsedCache.filter((prop: string) => allowedProps.includes(prop));
return;
} catch (e) {
console.error('解析列缓存失败', e);
}
}
visibleColumnProps.value = allowedProps;
};
// 4. 监听:只要用户勾选了列,就存入本地缓存
watch(visibleColumnProps, (newVal) => {
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
}, { deep: true });
// 5. 全选功能的计算属性和事件
const isAllSelected = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length === allowedLength;
});
const isIndeterminate = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length < allowedLength;
});
const handleCheckAllChange = (val: boolean) => {
if (val) {
visibleColumnProps.value = allColumns.filter(c => hasColumnPermission(c.prop)).map(c => c.prop);
} else {
visibleColumnProps.value = [];
}
};
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, id: undefined, base_id: undefined as number | undefined,
@ -1067,76 +1120,56 @@ 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.company_name = item.company_name form.material_name = ''
form.material_name = item.name form.spec_model = ''
form.spec_model = item.spec form.category = ''
form.category = item.category form.unit = ''
form.unit = item.unit form.material_type = ''
form.material_type = item.type isCurrentMaterialInspectionRequired.value = false
// 保存强制质检标记 updateInspectionRules()
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false }
// 更新表单校验规则
updateInspectionRules()
checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口) const onMaterialSelected = async (item: any) => {
try { form.base_id = item.id
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: val } }) form.company_name = item.company_name
if (res.code === 200 && res.data.location) { form.material_name = item.name
form.warehouse_location = res.data.location form.spec_model = item.spec
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) form.category = item.category
} form.unit = item.unit
} catch (e) { form.material_type = item.type
console.error('获取历史库位失败', e) materialNameInput.value = item.name
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
updateInspectionRules()
checkHistoryAndSetMode(item.id)
try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
} }
} catch (e) {
console.error('获取历史库位失败', e)
} }
} }
@ -1325,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
} }
@ -1333,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 {
@ -1401,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()
@ -1457,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('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1631,7 +1692,39 @@ const handleSortChange = ({ column, prop, order }: any) => {
fetchData() fetchData()
} }
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } } const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除采购入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteBuyInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" 吗?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
// ------------------------------------ // ------------------------------------
// 打印逻辑 // 打印逻辑
@ -1661,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"
@ -123,9 +123,23 @@
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button> <el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click"> <el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template> <template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; margin-bottom: 10px; padding-bottom: 10px;">
<span style="font-weight: bold;">列展示设置</span>
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col> <template v-for="c in allColumns" :key="c.prop">
<el-col :span="8" v-if="hasColumnPermission(c.prop)">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</template>
</el-row> </el-row>
</el-checkbox-group> </el-checkbox-group>
</el-popover> </el-popover>
@ -193,6 +207,7 @@
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])" :src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))" :preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported preview-teleported
hide-on-click-modal
fit="cover" fit="cover"
lazy lazy
> >
@ -227,7 +242,7 @@
<el-icon><Printer/></el-icon> <el-icon><Printer/></el-icon>
</el-button> </el-button>
<el-button link type="primary" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除" @confirm="handleDelete(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm> <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -244,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">
@ -268,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>
@ -299,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;">
@ -389,7 +398,7 @@
<el-col :span="dialogStatus === 'update' ? 12 : 18"> <el-col :span="dialogStatus === 'update' ? 12 : 18">
<el-form-item label="成品实拍" prop="product_photo"> <el-form-item label="成品实拍" prop="product_photo">
<div class="upload-container" id="upload-product_photo"> <div class="upload-container" id="upload-product_photo">
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('product_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('product_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -403,7 +412,7 @@
<el-col :span="12"> <el-col :span="12">
<el-form-item label="质量报告" prop="quality_report_link"> <el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container" id="upload-quality_report_link"> <div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -416,7 +425,7 @@
<el-col :span="12"> <el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report_link"> <el-form-item label="检测报告" prop="inspection_report_link">
<div class="upload-container" id="upload-inspection_report_link"> <div class="upload-container" id="upload-inspection_report_link">
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('inspection_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -445,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
@ -529,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"
@ -540,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;"/>
@ -572,7 +584,7 @@
import { ref, reactive, onMounted, watch, computed } from 'vue' import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue' import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus' import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import request from '@/utils/request' import request from '@/utils/request'
import { import {
@ -607,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()
@ -649,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)
@ -660,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)
@ -708,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)
@ -745,11 +742,14 @@ const scannerDialogVisible = ref(false)
// 库位级联选择器数据 // 库位级联选择器数据
const warehouseOptions = ref<any[]>([]) const warehouseOptions = ref<any[]>([])
// ================= 第一步:声明基础数据 =================
// [核心优化] 所有列定义 // [核心优化] 所有列定义
const allColumns = [ 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 },
@ -810,15 +810,15 @@ const permissionMap: Record<string, string> = {
detail_link: 'inbound_product:detail_link', detail_link: 'inbound_product:detail_link',
} }
// 根据用户权限初始化列显示状态 // ================= 第二步:声明响应式变量 =================
// 初始化列显示状态(纯权限驱动,废除本地缓存) const visibleColumnProps = ref<string[]>([])
const initColumnPermissions = () => {
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
}
// 检查列权限 // ================= 第三步:按依赖顺序放置方法和监听 =================
// 1. 获取唯一缓存 Key
const getStorageKey = () => `MOM_INBOUND_PROD_COLS_${userStore.username || 'DEFAULT'}`;
// 2. 检查列权限(依赖 permissionMap
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
@ -827,6 +827,50 @@ const hasColumnPermission = (prop: string) => {
return code ? userStore.hasPermission(code) : false return code ? userStore.hasPermission(code) : false
} }
// 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey
const initColumnPermissions = () => {
const allowedProps = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop);
const cachedData = localStorage.getItem(getStorageKey());
if (cachedData) {
try {
const parsedCache = JSON.parse(cachedData);
visibleColumnProps.value = parsedCache.filter((prop: string) => allowedProps.includes(prop));
return;
} catch (e) {
console.error('解析列缓存失败', e);
}
}
visibleColumnProps.value = allowedProps;
};
// 4. 监听:只要用户勾选了列,就存入本地缓存
watch(visibleColumnProps, (newVal) => {
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
}, { deep: true });
// 5. 全选功能的计算属性和事件
const isAllSelected = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length === allowedLength;
});
const isIndeterminate = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length < allowedLength;
});
const handleCheckAllChange = (val: boolean) => {
if (val) {
visibleColumnProps.value = allColumns.filter(c => hasColumnPermission(c.prop)).map(c => c.prop);
} else {
visibleColumnProps.value = [];
}
};
// ★ 智能聚合:当无序列号权限时,按 SKU 聚合库存 // ★ 智能聚合:当无序列号权限时,按 SKU 聚合库存
const displayData = computed(() => { const displayData = computed(() => {
// 检查是否有序列号权限 // 检查是否有序列号权限
@ -861,7 +905,6 @@ const displayData = computed(() => {
}) })
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id'] const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref<string[]>([])
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, id: undefined, base_id: undefined as number | undefined,
@ -883,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 }
} }
@ -981,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防止脏数据
// 获取该物料历史入库库位(新增独立接口) form.bom_code = ''
try { form.bom_version = ''
const res = await request.get('/v1/inbound/product/last-location', { params: { base_id: val } }) bomOptions.value = []
if (res.code === 200 && res.data.location) { // 获取该物料历史入库库位
form.warehouse_location = res.data.location request.get('/v1/inbound/product/last-location', { params: { base_id: item.id } }).then((res: any) => {
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) if (res.code === 200 && res.data?.location) {
} form.warehouse_location = res.data.location
} catch (e) { ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
console.error('获取历史库位失败', e)
} }
} }).catch(() => {})
} }
// ------------------------------------ // ------------------------------------
@ -1097,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 // [新增]
} }
@ -1105,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 {
@ -1172,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) => {
@ -1201,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 }]
@ -1346,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('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1358,15 +1412,48 @@ const submitForm = async () => {
}) })
} }
const handleDelete = async (row: any) => { try { await deleteProductInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch(e) { ElMessage.error('删除失败') } } const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除成品入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteProductInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" `,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
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"
@ -127,18 +127,32 @@
<template #reference> <template #reference>
<el-button :icon="Setting" circle class="circle-btn" /> <el-button :icon="Setting" circle class="circle-btn" />
</template> </template>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; margin-bottom: 10px; padding-bottom: 10px;">
<span style="font-weight: bold;">列展示设置</span>
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<div class="col-group-title">基础信息</div> <div class="col-group-title">基础信息</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop"> <template v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox> <el-col :span="12" v-if="hasColumnPermission(c.prop)">
</el-col> <el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</template>
</el-row> </el-row>
<div class="col-group-title" style="margin-top:10px">生产与库存</div> <div class="col-group-title" style="margin-top:10px">生产与库存</div>
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop"> <template v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox> <el-col :span="12" v-if="hasColumnPermission(c.prop)">
</el-col> <el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</template>
</el-row> </el-row>
</el-checkbox-group> </el-checkbox-group>
</el-popover> </el-popover>
@ -216,6 +230,7 @@
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])" :src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))" :preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported preview-teleported
hide-on-click-modal
fit="cover" fit="cover"
lazy lazy
> >
@ -250,11 +265,7 @@
<el-icon><Printer/></el-icon> 打印 <el-icon><Printer/></el-icon> 打印
</el-button> </el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220"> <el-button link type="danger" size="default" @click="handleDelete(row)">删除</el-button>
<template #reference>
<el-button link type="danger" size="default">删除</el-button>
</template>
</el-popconfirm>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -277,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"
@ -307,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>
@ -343,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;">
@ -468,7 +466,7 @@
<el-col :span="24"> <el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo"> <el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container" id="upload-arrival_photo"> <div class="upload-container" id="upload-arrival_photo">
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -480,7 +478,7 @@
<el-col :span="24"> <el-col :span="24">
<el-form-item label="质量报告" prop="quality_report_link"> <el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container" id="upload-quality_report_link"> <div class="upload-container" id="upload-quality_report_link">
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload"> <el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload" :before-remove="handleBeforeRemove">
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
</el-upload> </el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div> <div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
@ -514,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
@ -592,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;"/>
@ -627,7 +628,7 @@
import {ref, reactive, onMounted, watch, computed} from 'vue' import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue' import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture, EditPen} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import {ElMessage, ElLoading} from 'element-plus' import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import request from '@/utils/request' import request from '@/utils/request'
import { import {
@ -717,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)
@ -765,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)
@ -802,6 +812,8 @@ const warehouseOptions = ref<any[]>([])
const entryMode = ref('batch') const entryMode = ref('batch')
const modeLocked = ref(false) const modeLocked = ref(false)
// ================= 第一步:声明基础数据 =================
// 列定义 // 列定义
const baseColumns = [ const baseColumns = [
{prop: 'company_name', label: '所属公司', sortable: true}, // [新增] {prop: 'company_name', label: '所属公司', sortable: true}, // [新增]
@ -838,14 +850,8 @@ const stockColumns = [
{prop: 'quality_report_link', label: '质量报告', minWidth: '100', sortable: false}, {prop: 'quality_report_link', label: '质量报告', minWidth: '100', sortable: false},
{prop: 'detail_link', label: '详情链接', minWidth: '100', sortable: false}, {prop: 'detail_link', label: '详情链接', minWidth: '100', sortable: false},
] ]
const allColumns = [...baseColumns, ...stockColumns]
// 初始化列显示状态(纯权限驱动,废除本地缓存) const allColumns = [...baseColumns, ...stockColumns]
const initColumnPermissions = () => {
visibleColumnProps.value = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop)
}
// 列与权限Code的映射关系数据库中的code // 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = { const permissionMap: Record<string, string> = {
@ -886,7 +892,15 @@ const permissionMap: Record<string, string> = {
detail_link: 'inbound_semi:detail_link', detail_link: 'inbound_semi:detail_link',
} }
// 检查列权限 // ================= 第二步:声明响应式变量 =================
const visibleColumnProps = ref<string[]>([])
// ================= 第三步:按依赖顺序放置方法和监听 =================
// 1. 获取唯一缓存 Key
const getStorageKey = () => `MOM_INBOUND_SEMI_COLS_${userStore.username || 'DEFAULT'}`;
// 2. 检查列权限(依赖 permissionMap
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
@ -895,8 +909,51 @@ const hasColumnPermission = (prop: string) => {
return code ? userStore.hasPermission(code) : false return code ? userStore.hasPermission(code) : false
} }
// 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey
const initColumnPermissions = () => {
const allowedProps = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop);
const cachedData = localStorage.getItem(getStorageKey());
if (cachedData) {
try {
const parsedCache = JSON.parse(cachedData);
visibleColumnProps.value = parsedCache.filter((prop: string) => allowedProps.includes(prop));
return;
} catch (e) {
console.error('解析列缓存失败', e);
}
}
visibleColumnProps.value = allowedProps;
};
// 4. 监听:只要用户勾选了列,就存入本地缓存
watch(visibleColumnProps, (newVal) => {
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
}, { deep: true });
// 5. 全选功能的计算属性和事件
const isAllSelected = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length === allowedLength;
});
const isIndeterminate = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length < allowedLength;
});
const handleCheckAllChange = (val: boolean) => {
if (val) {
visibleColumnProps.value = allColumns.filter(c => hasColumnPermission(c.prop)).map(c => c.prop);
} else {
visibleColumnProps.value = [];
}
};
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link'] const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
const visibleColumnProps = ref<string[]>([])
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, id: undefined, base_id: undefined as number | undefined,
@ -932,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 }
} }
@ -980,70 +1043,58 @@ 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.material_name = item.name form.spec_model = ''
form.spec_model = item.spec form.category = ''
form.category = item.category form.unit = ''
form.unit = item.unit form.material_type = ''
form.material_type = item.type form.bom_code = ''
checkHistoryAndSetMode(item.id) form.bom_version = ''
bomOptions.value = []
}
// 获取该物料历史入库库位(新增独立接口) const onMaterialSelected = async (item: any) => {
try { form.base_id = item.id
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: val } }) form.company_name = item.company_name
if (res.code === 200 && res.data.location) { form.material_name = item.name
form.warehouse_location = res.data.location form.spec_model = item.spec
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) form.category = item.category
} form.unit = item.unit
} catch (e) { form.material_type = item.type
console.error('获取历史库位失败', e) materialNameInput.value = item.name
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
checkHistoryAndSetMode(item.id)
try {
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
} }
} catch (e) {
console.error('获取历史库位失败', e)
} }
} }
@ -1190,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 // [新增]
} }
@ -1198,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 {
@ -1288,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 }]
@ -1429,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('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
@ -1438,7 +1517,40 @@ const submitForm = async () => {
}) })
} }
const handleDelete = async (row: any) => { try { await deleteSemiInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } } const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除半成品入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteSemiInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" 吗?`,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
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, batch_number: row.batch_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_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku }
@ -1446,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
handleSearchMaterial('') // 防御性拦截 1用户已选过物料form.base_id 有值)
} // 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
// 否则会清空 searchKeyword 和 materialOptions破坏用户正在编辑的搜索结果。
if (form.base_id) return
// 防御性拦截 2已经有搜索关键字或已经有下拉数据
// 同样不要重置、不要再请求默认列表
if (searchKeyword.value || materialOptions.value.length > 0) return
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,16 +456,28 @@ 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])
cartItems.value.push({ barcodeInput.value = ''
...item, return
out_quantity: 1,
price: 0
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
} }
// ★ 扫码加入前强校验:不在清单内或超量直接阻断
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({
...item,
out_quantity: 1,
price: 0
})
ElMessage.success(`添加成功: ${item.name}`)
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%; }
@ -621,4 +774,4 @@ onUnmounted(() => {
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; } .sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; } .sidebar-actions .el-button { flex: 1; height: 40px; }
} }
</style> </style>

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 type="expand">
<template #default="props">
<div style="padding: 10px 40px;">
<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}">
<el-tag type="info">{{ row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已还数量" width="80" align="center">
<template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="80" align="center">
<template #default="{row}">
<el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag>
<el-tag v-else type="success">0</el-tag>
</template>
</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('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('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="借出数量" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已还数量" width="90" align="center">
<template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="90" align="center">
<template #default="{row}">
<el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag>
<el-tag v-else type="success">0</el-tag>
</template>
</el-table-column>
<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 }
} }