Compare commits
1 Commits
00839863f5
...
2.0权限管理
| Author | SHA1 | Date | |
|---|---|---|---|
| 259f3a7e0d |
@ -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
|
||||||
|
|||||||
BIN
db_sync.sql.gz
BIN
db_sync.sql.gz
Binary file not shown.
BIN
deploy.tar.gz
BIN
deploy.tar.gz
Binary file not shown.
0
deploy_code.sh
Normal file → Executable file
0
deploy_code.sh
Normal file → Executable file
0
deploy_full.sh
Normal file → Executable file
0
deploy_full.sh
Normal file → Executable file
Binary file not shown.
@ -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. 预加载数据模型
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
69
inventory-backend/app/api/v1/scan/__init__.py
Normal file
69
inventory-backend/app/api/v1/scan/__init__.py
Normal 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
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
@ -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:
|
||||||
|
|||||||
199
inventory-backend/app/services/inventory_task.py
Normal file
199
inventory-backend/app/services/inventory_task.py
Normal 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')
|
||||||
|
}
|
||||||
@ -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-已完成
|
||||||
|
|||||||
@ -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
|
||||||
|
---
|
||||||
|
|
||||||
请登录仓库管理系统进行审批。
|
请登录仓库管理系统进行审批。
|
||||||
|
|
||||||
此邮件由系统自动发送,请勿回复。
|
此邮件由系统自动发送,请勿回复。
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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">红色阈值 < 库存 ≤ 此值时显示黄色预警</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.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>
|
||||||
Reference in New Issue
Block a user