Compare commits
63 Commits
81ea4a0ab3
...
3.0AI添加
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ba1ff37f0 | |||
| 1450e6c1de | |||
| b9c25ff4c5 | |||
| b79b0f99af | |||
| 83b3db693a | |||
| bfeb397c4a | |||
| dcef91c3b1 | |||
| 5c0c1632c3 | |||
| 6f5652b90e | |||
| 7ef22a3830 | |||
| 941bd20fbd | |||
| 7ee6b0e02f | |||
| 9e83c31f39 | |||
| 6ad00884ba | |||
| 9a5e3ee6b0 | |||
| 67bc5b6c5d | |||
| 6cf5a25d77 | |||
| 6686747e57 | |||
| 74bc751624 | |||
| c7b84ff3c6 | |||
| 0e6d294052 | |||
| 93b9846fc6 | |||
| 1def8c7747 | |||
| 907c083107 | |||
| afe0f25415 | |||
| ffc482bd9e | |||
| 7087769a33 | |||
| 3d30cbc5c2 | |||
| 355a21e94c | |||
| ff5418afa3 | |||
| d94b52bf73 | |||
| 8bb3e58b44 | |||
| cdac915a4b | |||
| 8a2da1ac1e | |||
| 332ae3c4cf | |||
| d51c6f147f | |||
| 2977acbae7 | |||
| 90eed24441 | |||
| 91444034e0 | |||
| 8f901e3f08 | |||
| bac670ef7a | |||
| 1c0c02fd36 | |||
| fffee9d964 | |||
| a3d47f6328 | |||
| 6149662fd8 | |||
| f18dfd9819 | |||
| 992e08aee9 | |||
| f27488e693 | |||
| 034418df8a | |||
| cd54ca3fe2 | |||
| 05aff2dd83 | |||
| c1d364b786 | |||
| 6e50762da6 | |||
| b4945cbba4 | |||
| 7d828d3ebf | |||
| 7e09e9de31 | |||
| fb5b8d873b | |||
| 682139bab8 | |||
| e564c5a5d2 | |||
| 9406669f1c | |||
| 92e1f7275e | |||
| 895d78a5e7 | |||
| 567c3175f6 |
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(git add *)",
|
|
||||||
"Bash(git commit *)",
|
|
||||||
"Bash(git *)",
|
|
||||||
"Bash(del *)",
|
|
||||||
"Bash(rm *)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"$version": 3
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(git add *)",
|
|
||||||
"Bash(git commit *)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
deploy.tar.gz
BIN
deploy.tar.gz
Binary file not shown.
@ -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
|
||||||
@ -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:
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
venv/
|
.git
|
||||||
__pycache__/
|
.idea
|
||||||
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
.git/
|
*.pyo
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
|
env
|
||||||
|
uploads
|
||||||
|
pgdata
|
||||||
.env
|
.env
|
||||||
pgdata/
|
simhei.ttf
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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})
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
38
inventory-backend/app/models/bom_draft.py
Normal file
38
inventory-backend/app/models/bom_draft.py
Normal 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
|
||||||
|
)
|
||||||
96
inventory-backend/app/models/borrow.py
Normal file
96
inventory-backend/app/models/borrow.py
Normal 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})"
|
||||||
145
inventory-backend/app/services/bom_draft_service.py
Normal file
145
inventory-backend/app/services/bom_draft_service.py
Normal 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
|
||||||
@ -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
|
||||||
401
inventory-backend/app/services/borrow_service.py
Normal file
401
inventory-backend/app/services/borrow_service.py
Normal 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
|
||||||
@ -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()))
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
BIN
inventory-backend/app/services/print/simhei.ttf
Normal file
BIN
inventory-backend/app/services/print/simhei.ttf
Normal file
Binary file not shown.
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
25
inventory-backend/app/utils/executor.py
Normal file
25
inventory-backend/app/utils/executor.py
Normal 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)
|
||||||
@ -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 推理依赖)
|
||||||
|
|||||||
@ -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="请输入有效邮箱地址" />
|
||||||
|
|||||||
@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以图搜图响应结构 */
|
/** 以图搜图响应结构 */
|
||||||
|
|||||||
@ -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 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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
1106
inventory-web/src/views/borrow/apply/index.vue
Normal file
1106
inventory-web/src/views/borrow/apply/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
365
inventory-web/src/views/borrow/approval/index.vue
Normal file
365
inventory-web/src/views/borrow/approval/index.vue
Normal 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>
|
||||||
1548
inventory-web/src/views/material/buyOdoo.vue
Normal file
1548
inventory-web/src/views/material/buyOdoo.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 的条目并 push,picture-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);
|
||||||
|
|||||||
@ -527,12 +527,14 @@ const totalExportCount = computed(() => {
|
|||||||
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(N),无嵌套循环)---
|
// --- BOM 齐套性分析计算属性(使用后端已计算的 current_stock,O(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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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: '', // [新增]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user