1 Commits

Author SHA1 Message Date
dxc
259f3a7e0d 4.29扫码获取库位小工具接口 2026-04-29 15:40:43 +08:00
17 changed files with 455 additions and 64 deletions

View File

@ -4,7 +4,8 @@
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit *)", "Bash(git commit *)",
"Bash(git *)", "Bash(git *)",
"Bash(del *)" "Bash(del *)",
"Bash(rm *)"
] ]
}, },
"$version": 3 "$version": 3

Binary file not shown.

Binary file not shown.

0
deploy_code.sh Normal file → Executable file
View File

0
deploy_full.sh Normal file → Executable file
View File

Binary file not shown.

View File

@ -3,6 +3,7 @@
from flask import Flask from flask import Flask
from config import Config from config import Config
from app.extensions import db, migrate, cors, jwt from app.extensions import db, migrate, cors, jwt
from app.api.v1.scan import scan_bp
import os import os
@ -213,6 +214,15 @@ def create_app():
except ImportError as e: except ImportError as e:
print(f"❌ 错误: Common 模块导入失败: {e}") print(f"❌ 错误: Common 模块导入失败: {e}")
# -----------------------------------------------------
# 2.13 注册扫码查库存模块 (Scan)
# -----------------------------------------------------
try:
app.register_blueprint(scan_bp, url_prefix='/api/v1/scan')
print("✅ Scan 模块注册成功")
except Exception as e:
print(f"❌ 错误: Scan 模块注册失败: {e}")
# ========================================================= # =========================================================
# 3. 预加载数据模型 # 3. 预加载数据模型
# ========================================================= # =========================================================

View File

@ -2,8 +2,10 @@ from flask import Blueprint
from .inbound import inbound_bp from .inbound import inbound_bp
from .bom import bom_bp from .bom import bom_bp
from .common import common_bp from .common import common_bp
from .scan import scan_bp
v1_bp = Blueprint('v1', __name__) v1_bp = Blueprint('v1', __name__)
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound') v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
v1_bp.register_blueprint(bom_bp, url_prefix='/bom') v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
v1_bp.register_blueprint(common_bp, url_prefix='/common') v1_bp.register_blueprint(common_bp, url_prefix='/common')
v1_bp.register_blueprint(scan_bp, url_prefix='/scan')

View File

@ -381,6 +381,8 @@ def batch_set_warning():
red_val = item.get('redThreshold') red_val = item.get('redThreshold')
warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0 warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0
warning.red_threshold = float(red_val) if red_val is not None else 0 warning.red_threshold = float(red_val) if red_val is not None else 0
warning.yellow_emails = item.get('yellowEmails', warning.yellow_emails)
warning.red_emails = item.get('redEmails', warning.red_emails)
updated_count += 1 updated_count += 1
else: else:
# 创建新记录 # 创建新记录
@ -390,7 +392,9 @@ def batch_set_warning():
base_id=base_id, base_id=base_id,
is_enabled=item.get('isEnabled', False), is_enabled=item.get('isEnabled', False),
yellow_threshold=float(yellow_val) if yellow_val is not None else 0, yellow_threshold=float(yellow_val) if yellow_val is not None else 0,
red_threshold=float(red_val) if red_val is not None else 0 red_threshold=float(red_val) if red_val is not None else 0,
yellow_emails=item.get('yellowEmails', ''),
red_emails=item.get('redEmails', '')
) )
db.session.add(warning) db.session.add(warning)
created_count += 1 created_count += 1
@ -412,7 +416,48 @@ def batch_set_warning():
# ============================================================================== # ==============================================================================
# 2.6 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection) # 2.6 标记已采购 API (POST /api/v1/inbound/base/warning/mark-ordered)
# ==============================================================================
@inbound_base_bp.route('/warning/mark-ordered', methods=['POST'])
@permission_required('material_list:edit_warning')
def mark_warning_ordered():
"""
前端标记预警物料已处理采购(标记 is_ordered
请求体格式: {"baseId": 123, "isOrdered": true}
"""
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
base_id = data.get('baseId')
if not base_id:
return jsonify({"code": 400, "msg": "baseId 不能为空"}), 400
is_ordered = bool(data.get('isOrdered', False))
warning = MaterialWarningSetting.query.filter_by(base_id=base_id).first()
if not warning:
return jsonify({"code": 404, "msg": f"物料ID {base_id} 的预警配置不存在"}), 404
warning.is_ordered = is_ordered
db.session.commit()
status_text = "已标记为已采购" if is_ordered else "已重置为未采购"
return jsonify({
"code": 200,
"msg": status_text,
"data": warning.to_dict()
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"标记已采购失败: {str(e)}")
return jsonify({"code": 500, "msg": f"标记已采购失败: {str(e)}"}), 500
# ==============================================================================
# 2.7 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/batch-inspection', methods=['POST']) @inbound_base_bp.route('/batch-inspection', methods=['POST'])
@permission_required('material_list:operation') @permission_required('material_list:operation')

View File

@ -0,0 +1,69 @@
"""
扫码查库存接口(移动端专用)
GET /api/v1/scan/inventory?barcode=xxx
"""
from flask import Blueprint, jsonify, request
from app.extensions import db
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.inbound.semi import StockSemi
scan_bp = Blueprint('scan', __name__, url_prefix='/scan')
def _build_response(stock_record, stock_type: str) -> dict:
"""联表 MaterialBase 提取物料信息并组装返回结构"""
material = MaterialBase.query.get(stock_record.base_id)
return {
'code': 200,
'data': {
'materialName': material.name if material else '未知物料',
'spec': material.spec_model if material else '',
'location': stock_record.warehouse_location or '',
'quantity': float(stock_record.available_quantity) if stock_record.available_quantity else 0.0,
'stockType': stock_type
}
}
@scan_bp.route('/inventory', methods=['GET'])
def scan_inventory():
"""
扫码精确查找库存
入参: barcode (query string)
逻辑: 在 StockBuy / StockProduct / StockSemi 三表中精确匹配,只要命中一张即返回
"""
barcode = (request.args.get('barcode') or '').strip()
if not barcode:
return jsonify({'code': 400, 'msg': 'barcode 参数不能为空'}), 400
# 1. 采购库
buy = StockBuy.query.filter(
StockBuy.barcode == barcode,
StockBuy.stock_quantity > 0
).first()
if buy:
return jsonify(_build_response(buy, '采购库'))
# 2. 成品库
product = StockProduct.query.filter(
StockProduct.barcode == barcode,
StockProduct.stock_quantity > 0
).first()
if product:
return jsonify(_build_response(product, '成品库'))
# 3. 半成品库
semi = StockSemi.query.filter(
StockSemi.barcode == barcode,
StockSemi.stock_quantity > 0
).first()
if semi:
return jsonify(_build_response(semi, '半成品库'))
# 4. 全部未命中
return jsonify({
'code': 404,
'msg': f'未找到条码 [{barcode}] 对应的库存记录,或该物料当前库存为零'
}), 404

View File

@ -101,6 +101,10 @@ class MaterialWarningSetting(db.Model):
is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警') is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警')
yellow_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='黄色预警阈值') yellow_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='黄色预警阈值')
red_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='红色预警阈值') red_threshold = db.Column(db.Numeric(10, 2), nullable=True, comment='红色预警阈值')
yellow_emails = db.Column(db.String(500), nullable=True, comment='黄色预警通知邮箱')
red_emails = db.Column(db.String(500), nullable=True, comment='红色预警通知邮箱')
is_ordered = db.Column(db.Boolean, default=False, comment='是否已处理采购')
last_notified_at = db.Column(db.DateTime, nullable=True, comment='上次邮件通知时间')
# 关联关系 # 关联关系
material = db.relationship('MaterialBase', back_populates='warning_settings') material = db.relationship('MaterialBase', back_populates='warning_settings')
@ -111,5 +115,9 @@ class MaterialWarningSetting(db.Model):
'baseId': self.base_id, 'baseId': self.base_id,
'isEnabled': bool(self.is_enabled), 'isEnabled': bool(self.is_enabled),
'yellowThreshold': float(self.yellow_threshold) if self.yellow_threshold is not None else None, 'yellowThreshold': float(self.yellow_threshold) if self.yellow_threshold is not None else None,
'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None 'redThreshold': float(self.red_threshold) if self.red_threshold is not None else None,
'yellowEmails': self.yellow_emails or '',
'redEmails': self.red_emails or '',
'isOrdered': bool(self.is_ordered),
'lastNotifiedAt': self.last_notified_at.strftime('%Y-%m-%d %H:%M:%S') if self.last_notified_at else None
} }

View File

@ -375,34 +375,29 @@ class MaterialBaseService:
if enable_warning_sort: if enable_warning_sort:
print("====== [DEBUG] 成功进入预警强排逻辑 ======") print("====== [DEBUG] 成功进入预警强排逻辑 ======")
# 直接在 order_by 中进行计算排序,不污染 select 列
inv_val = inner_sub.c.total_inv inv_val = inner_sub.c.total_inv
red_val = cast(MaterialWarningSetting.red_threshold, Numeric) red_val = cast(MaterialWarningSetting.red_threshold, Numeric)
yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric) yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric)
# 预警等级计算:红=2, 黄=1, 正常=0 # 预警等级计算:红=2, 黄=1, 正常=0
warning_level = case( warning_level = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), 2), (and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), 2),
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= yellow_val), 1), (and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), 1),
else_=0
)
# 统一计算缺口 (Shortage) = 目标阈值 - 当前库存
# 红色算红色的缺口,黄色算黄色的缺口,越大说明缺的越多
shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), red_val - inv_val),
(and_(MaterialWarningSetting.is_enabled.is_(True), yellow_val.isnot(None), inv_val <= yellow_val), yellow_val - inv_val),
else_=0 else_=0
) )
# 红色预警时的缺口
red_shortage = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), red_val - inv_val),
else_=0
)
# 黄色预警时的缺口
yellow_distance = case(
(and_(MaterialWarningSetting.is_enabled.is_(True), inv_val > red_val, inv_val <= yellow_val), inv_val - red_val),
else_=999999
)
# 直接在 order_by 中使用 case() 表达式
query = query.order_by( query = query.order_by(
desc(warning_level), desc(warning_level), # 1. 先按红、黄、正常排
desc(red_shortage), desc(shortage), # 2. 同级别内,缺口越大的排越上面
asc(yellow_distance), desc(inv_val), # 3. 缺口一样,库存多的排上面
desc(inv_val),
desc(MaterialBase.id) desc(MaterialBase.id)
) )
elif order_by_column: elif order_by_column:
@ -462,12 +457,18 @@ class MaterialBaseService:
item_dict['warningRed'] = float(warning_red) if warning_red is not None else None item_dict['warningRed'] = float(warning_red) if warning_red is not None else None
# 计算预警状态 # 计算预警状态
if warning_enabled and warning_red is not None: if warning_enabled:
invQty = item_dict['inventoryCount'] invQty = item_dict['inventoryCount']
if invQty <= warning_red:
# 优先判断红色预警(如果设置了红阈值,且库存 <= 红阈值)
if warning_red is not None and invQty <= warning_red:
item_dict['warningStatus'] = 2 # 红色 item_dict['warningStatus'] = 2 # 红色
# 其次判断黄色预警(如果设置了黄阈值,且库存 <= 黄阈值)
elif warning_yellow is not None and invQty <= warning_yellow: elif warning_yellow is not None and invQty <= warning_yellow:
item_dict['warningStatus'] = 1 # 黄色 item_dict['warningStatus'] = 1 # 黄色
# 都不满足则正常
else: else:
item_dict['warningStatus'] = 0 # 正常 item_dict['warningStatus'] = 0 # 正常
else: else:

View File

@ -0,0 +1,199 @@
"""
库存预警扫描与邮件通知服务
定时(或手动触发)扫描所有 is_enabled=True 且 is_ordered=False 的预警配置,
按物料配置的邮箱独立发送,不依赖 SysUser 角色。
- 库存 <= red_threshold → 红色预警邮件(发 setting.red_emails
- red_threshold < 库存 <= yellow_threshold → 黄色预警邮件(发 setting.yellow_emails
- 同一收件人在多条记录中出现 → 聚合为一封邮件
- 发送成功后更新 last_notified_at
"""
from datetime import datetime, timezone, timedelta
from collections import defaultdict
from sqlalchemy import func
from app.extensions import db
from app.models.base import MaterialBase, MaterialWarningSetting
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
class InventoryWarningService:
@staticmethod
def _get_total_inventory(base_id: int) -> float:
"""
计算指定物料在所有库存表(采购件 + 半成品 + 成品)中的总库存量
"""
buy_q = db.session.query(func.sum(StockBuy.stock_quantity)).filter(
StockBuy.base_id == base_id
).scalar() or 0
semi_q = db.session.query(func.sum(StockSemi.stock_quantity)).filter(
StockSemi.base_id == base_id
).scalar() or 0
prod_q = db.session.query(func.sum(StockProduct.stock_quantity)).filter(
StockProduct.base_id == base_id
).scalar() or 0
return float(buy_q) + float(semi_q) + float(prod_q)
@staticmethod
def _parse_emails(email_str: str) -> list:
"""从逗号分隔字符串中提取并清洗有效邮箱列表"""
if not email_str or not email_str.strip():
return []
return [e.strip() for e in email_str.split(',') if e.strip() and '@' in e.strip()]
@staticmethod
def _build_text_table(rows: list, level: str) -> str:
"""
构建纯文本物料清单表格
Args:
rows: [{"name": ..., "spec": ..., "qty": ..., "threshold": ...}, ...]
level: "red""yellow",决定阈值列标题
"""
threshold_label = "红色阈值" if level == "red" else "黄色阈值"
lines = [
"名称 | 规格 | 当前库存 | " + threshold_label,
"-" * 60,
]
for r in rows:
name = r.get('name', '-') or '-'
spec = r.get('spec', '-') or '-'
qty = r.get('qty', '-')
th = r.get('threshold', '-')
lines.append(f"{name} | {spec} | {qty} | {th}")
return '\n'.join(lines)
@staticmethod
def check_and_send_warning_emails() -> dict:
"""
执行库存预警扫描与邮件发送
1. 查询所有 is_enabled=True 且 is_ordered=False 的预警配置
2. 按 level 归类物料,按邮箱聚合(同一邮箱 → 一封邮件)
3. 调用 send_email 发送,更新 last_notified_at
Returns:
{
"red_count": N, # 触发红色预警的物料数
"yellow_count": N, # 触发黄色预警的物料数
"red_sent": True/False,
"yellow_sent": True/False,
"timestamp": "..."
}
"""
from app.utils.email_service import send_email
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
# 查询启用了预警且未标记采购的配置
settings = MaterialWarningSetting.query.filter(
MaterialWarningSetting.is_enabled == True,
MaterialWarningSetting.is_ordered == False
).all()
red_rows_by_email = defaultdict(list) # email -> [物料row, ...]
yellow_rows_by_email = defaultdict(list)
total_red = 0
total_yellow = 0
sent_red = False
sent_yellow = False
processed_settings = []
for setting in settings:
base_id = setting.base_id
material = MaterialBase.query.get(base_id)
if not material:
continue
name = material.name
spec = material.spec_model or ''
red_th = float(setting.red_threshold) if setting.red_threshold is not None else None
yellow_th = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
inv = InventoryWarningService._get_total_inventory(base_id)
# ★ 红色预警:库存 <= red_threshold走 setting.red_emails ★
if red_th is not None and inv <= red_th:
total_red += 1
red_emails = InventoryWarningService._parse_emails(setting.red_emails)
if red_emails:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(red_th, 2),
}
for email in red_emails:
red_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」红单跳过:无 red_emails 配置")
# ★ 黄色预警red_threshold < 库存 <= yellow_threshold走 setting.yellow_emails ★
elif (
(red_th is not None and yellow_th is not None and red_th < inv <= yellow_th)
or (red_th is None and yellow_th is not None and inv <= yellow_th)
):
total_yellow += 1
yellow_emails = InventoryWarningService._parse_emails(setting.yellow_emails)
if yellow_emails:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(yellow_th, 2),
}
for email in yellow_emails:
yellow_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」黄单跳过:无 yellow_emails 配置")
else:
continue
# ★ 按邮箱聚合,批量发送红色预警邮件 ★
for email, rows in red_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'red')
subject = f"【红色预警】库存告急(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到红色预警阈值,请立即处理采购:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email(email, subject, content)
sent_red = True
# ★ 按邮箱聚合,批量发送黄色预警邮件 ★
for email, rows in yellow_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'yellow')
subject = f"【黄色预警】库存偏低(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到黄色预警阈值,请关注采购进度:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email(email, subject, content)
sent_yellow = True
# ★ 批量更新 last_notified_at ★
if processed_settings:
for s in processed_settings:
s.last_notified_at = now
db.session.commit()
return {
'red_count': total_red,
'yellow_count': total_yellow,
'red_sent': sent_red,
'yellow_sent': sent_yellow,
'timestamp': now.strftime('%Y-%m-%d %H:%M:%S')
}

View File

@ -253,6 +253,13 @@ class OutboundService:
) )
db.session.add(new_record) db.session.add(new_record)
# ★ 出库后检查低库存预警
try:
from app.utils.stock_alert import check_and_alert
check_and_alert(stock_record.base_id)
except Exception as e:
current_app.logger.warning(f"⚠️ 低库存预警检查失败: {e}")
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成" # ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
if approval: if approval:
approval.status = 3 # 3-已完成 approval.status = 3 # 3-已完成

View File

@ -159,6 +159,12 @@ def send_new_request_notify(to_emails: List[str], request_no: str,
物料清单如下: 物料清单如下:
{chr(10).join(rows)} {chr(10).join(rows)}
---
⚡ 快速通道:
请点击下方链接直接进入系统审批:
https://172.16.0.198/outbound/approval
---
请登录仓库管理系统进行审批。 请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。 此邮件由系统自动发送,请勿回复。

View File

@ -78,3 +78,12 @@ export function getLatestSpecs() {
method: 'get' method: 'get'
}) })
} }
// 8. 标记预警物料已采购
export function markWarningOrdered(data: { baseId: number; isOrdered: boolean }) {
return request({
url: '/inbound/base/warning/mark-ordered',
method: 'post',
data
})
}

View File

@ -302,10 +302,14 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" min-width="200" fixed="right" align="center"> <el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" width="280" fixed="right" align="center">
<template #default="scope"> <template #default="scope">
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button> <el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button> <el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
</template>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button> <el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@ -560,10 +564,16 @@
<el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" /> <el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" />
<div class="form-tip">库存数量 ≤ 此值时显示红色预警</div> <div class="form-tip">库存数量 ≤ 此值时显示红色预警</div>
</el-form-item> </el-form-item>
<el-form-item label="红色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.redEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
<el-form-item label="黄色阈值" prop="yellowThreshold" v-if="warningForm.isEnabled"> <el-form-item label="黄色阈值" prop="yellowThreshold" v-if="warningForm.isEnabled">
<el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为黄色预警" style="width: 100%" /> <el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为黄色预警" style="width: 100%" />
<div class="form-tip">红色阈值 &lt; 库存 ≤ 此值时显示黄色预警</div> <div class="form-tip">红色阈值 &lt; 库存 ≤ 此值时显示黄色预警</div>
</el-form-item> </el-form-item>
<el-form-item label="黄色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.yellowEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
@ -622,7 +632,8 @@ import {
getMaterialBaseOptions, getMaterialBaseOptions,
exportAssetStatistics, exportAssetStatistics,
batchSetWarning, batchSetWarning,
batchSetInspection batchSetInspection,
markWarningOrdered
} from '@/api/material_base'; } from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'; import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
@ -646,6 +657,8 @@ interface MaterialBaseVO {
statusLoading?: boolean; statusLoading?: boolean;
inventoryCount?: number; inventoryCount?: number;
availableCount?: number; availableCount?: number;
warningStatus?: number;
warningOrdered?: boolean;
} }
interface QueryParams { interface QueryParams {
@ -791,7 +804,9 @@ const warningLoading = ref(false);
const warningForm = reactive({ const warningForm = reactive({
isEnabled: false, isEnabled: false,
redThreshold: undefined as number | undefined, redThreshold: undefined as number | undefined,
yellowThreshold: undefined as number | undefined yellowThreshold: undefined as number | undefined,
redEmails: '',
yellowEmails: ''
}); });
const warningRules = { const warningRules = {
yellowThreshold: [ yellowThreshold: [
@ -1388,11 +1403,34 @@ const handleSetSingleWarning = (row: MaterialBaseVO) => {
warningForm.isEnabled = row.warningEnabled || false; warningForm.isEnabled = row.warningEnabled || false;
warningForm.redThreshold = row.warningRed; warningForm.redThreshold = row.warningRed;
warningForm.yellowThreshold = row.warningYellow; warningForm.yellowThreshold = row.warningYellow;
warningForm.redEmails = (row as any).redEmails || '';
warningForm.yellowEmails = (row as any).yellowEmails || '';
warningDialog.title = '设置预警'; warningDialog.title = '设置预警';
warningDialog.visible = true; warningDialog.visible = true;
}; };
// 标记预警物料已采购
const handleMarkOrdered = (row: MaterialBaseVO) => {
ElMessageBox.confirm(
'确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。',
'确认标记已采购',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await markWarningOrdered({ baseId: row.id, isOrdered: true });
ElMessage.success('已标记为已采购');
getList();
} catch (error: any) {
ElMessage.error(error?.msg || '标记失败');
}
}).catch(() => {});
};
// 提交预警设置 // 提交预警设置
const submitWarning = async () => { const submitWarning = async () => {
if (!warningFormRef.value) return; if (!warningFormRef.value) return;
@ -1415,7 +1453,9 @@ const submitWarning = async () => {
baseId, baseId,
isEnabled: warningForm.isEnabled, isEnabled: warningForm.isEnabled,
redThreshold: red, redThreshold: red,
yellowThreshold: yellow yellowThreshold: yellow,
redEmails: warningForm.redEmails || '',
yellowEmails: warningForm.yellowEmails || ''
})); }));
await batchSetWarning(data); await batchSetWarning(data);
@ -1463,17 +1503,13 @@ const submitBatchInspection = async () => {
// 表格行样式(根据预警状态) // 表格行样式(根据预警状态)
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => { const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
// 只有拥有 view_warning 权限且有预警状态时才显示特殊样式 if (row.warningStatus === 2) {
if (!userStore.hasPermission('material_list:view_warning')) return ''; return 'danger-row'; // 红色预警
} else if (row.warningStatus === 1) {
const status = (row as any).warningStatus; return 'warning-row'; // 黄色预警
if (status === 2) {
return 'warning-row-red'; // 红色预警
} else if (status === 1) {
return 'warning-row-yellow'; // 黄色预警
} }
return ''; return '';
}; }
// --- 文件上传辅助函数 --- // --- 文件上传辅助函数 ---
@ -1715,34 +1751,32 @@ onMounted(() => {
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; } .upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; }
.upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; } .upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
/* 预警行样式 - 加深颜色 */ /* ================================================================
:deep(.warning-row-red) { Element Plus 表格预警行样式 & 固定列重叠修复
--el-table-tr-bg-color: #ffcdd2 !important; ================================================================ */
background-color: #ffcdd2 !important;
} /* 黄色预警行底色 (全覆盖) */
:deep(.warning-row-red td) { :deep(.el-table .warning-row),
background-color: transparent !important; :deep(.el-table .warning-row > td.el-table__cell) {
} background-color: #fcedc4 !important; /* 明显的黄色 */
:deep(.warning-row-yellow) {
--el-table-tr-bg-color: #fff59d !important;
background-color: #fff59d !important;
}
:deep(.warning-row-yellow td) {
background-color: transparent !important;
} }
/* 表单提示文字 */ /* 红色预警行底色 (全覆盖) */
.form-tip { :deep(.el-table .danger-row),
font-size: 12px; :deep(.el-table .danger-row > td.el-table__cell) {
color: #909399; background-color: #fcd3d3 !important; /* 明显的红色 */
margin-top: 4px;
line-height: 1.4;
} }
</style>
<style> /* 固定列的按钮容器底色跟随所在行的背景色,视觉无缝融合 */
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */ :deep(.el-table .el-table__cell.is-fixed) {
.long-dropdown .el-select-dropdown__wrap { background-color: inherit !important;
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */ }
/* 按钮间距微调,更紧凑 */
:deep(.el-table .el-table__cell.is-fixed .cell) {
display: flex;
gap: 6px;
justify-content: flex-start; /* 左对齐更自然 */
flex-wrap: nowrap; /* 尽量不换行 */
} }
</style> </style>