diff --git a/.qwen/settings.json b/.qwen/settings.json index 957c89d..64ed307 100644 --- a/.qwen/settings.json +++ b/.qwen/settings.json @@ -4,7 +4,8 @@ "Bash(git add *)", "Bash(git commit *)", "Bash(git *)", - "Bash(del *)" + "Bash(del *)", + "Bash(rm *)" ] }, "$version": 3 diff --git a/db_sync.sql.gz b/db_sync.sql.gz index 8d3325e..12ff62e 100644 Binary files a/db_sync.sql.gz and b/db_sync.sql.gz differ diff --git a/deploy.tar.gz b/deploy.tar.gz index ef9cee9..8cb6d35 100644 Binary files a/deploy.tar.gz and b/deploy.tar.gz differ diff --git a/deploy_code.sh b/deploy_code.sh old mode 100644 new mode 100755 diff --git a/deploy_full.sh b/deploy_full.sh old mode 100644 new mode 100755 diff --git a/deploy_full.tar.gz b/deploy_full.tar.gz index 3154470..f055fbb 100644 Binary files a/deploy_full.tar.gz and b/deploy_full.tar.gz differ diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index a35c4e2..b58a7aa 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -3,6 +3,7 @@ from flask import Flask from config import Config from app.extensions import db, migrate, cors, jwt +from app.api.v1.scan import scan_bp import os @@ -213,6 +214,15 @@ def create_app(): except ImportError as 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. 预加载数据模型 # ========================================================= diff --git a/inventory-backend/app/api/v1/__init__.py b/inventory-backend/app/api/v1/__init__.py index a467a45..65b938e 100644 --- a/inventory-backend/app/api/v1/__init__.py +++ b/inventory-backend/app/api/v1/__init__.py @@ -2,8 +2,10 @@ from flask import Blueprint from .inbound import inbound_bp from .bom import bom_bp from .common import common_bp +from .scan import scan_bp v1_bp = Blueprint('v1', __name__) v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound') v1_bp.register_blueprint(bom_bp, url_prefix='/bom') v1_bp.register_blueprint(common_bp, url_prefix='/common') +v1_bp.register_blueprint(scan_bp, url_prefix='/scan') diff --git a/inventory-backend/app/api/v1/inbound/base.py b/inventory-backend/app/api/v1/inbound/base.py index c4462bb..2cefcba 100644 --- a/inventory-backend/app/api/v1/inbound/base.py +++ b/inventory-backend/app/api/v1/inbound/base.py @@ -381,6 +381,8 @@ def batch_set_warning(): red_val = item.get('redThreshold') 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.yellow_emails = item.get('yellowEmails', warning.yellow_emails) + warning.red_emails = item.get('redEmails', warning.red_emails) updated_count += 1 else: # 创建新记录 @@ -390,7 +392,9 @@ def batch_set_warning(): base_id=base_id, is_enabled=item.get('isEnabled', False), 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) 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']) @permission_required('material_list:operation') diff --git a/inventory-backend/app/api/v1/scan/__init__.py b/inventory-backend/app/api/v1/scan/__init__.py new file mode 100644 index 0000000..f823eba --- /dev/null +++ b/inventory-backend/app/api/v1/scan/__init__.py @@ -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 diff --git a/inventory-backend/app/models/base.py b/inventory-backend/app/models/base.py index 45b6163..8607848 100644 --- a/inventory-backend/app/models/base.py +++ b/inventory-backend/app/models/base.py @@ -101,6 +101,10 @@ class MaterialWarningSetting(db.Model): is_enabled = db.Column(db.Boolean, default=False, comment='是否启用预警') yellow_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') @@ -111,5 +115,9 @@ class MaterialWarningSetting(db.Model): 'baseId': self.base_id, 'isEnabled': bool(self.is_enabled), '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 } \ No newline at end of file diff --git a/inventory-backend/app/services/inbound/base_service.py b/inventory-backend/app/services/inbound/base_service.py index ac50d8d..c0b0af5 100644 --- a/inventory-backend/app/services/inbound/base_service.py +++ b/inventory-backend/app/services/inbound/base_service.py @@ -375,34 +375,29 @@ class MaterialBaseService: if enable_warning_sort: print("====== [DEBUG] 成功进入预警强排逻辑 ======") - # 直接在 order_by 中进行计算排序,不污染 select 列 inv_val = inner_sub.c.total_inv red_val = cast(MaterialWarningSetting.red_threshold, Numeric) yellow_val = cast(MaterialWarningSetting.yellow_threshold, Numeric) # 预警等级计算:红=2, 黄=1, 正常=0 warning_level = case( - (and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= red_val), 2), - (and_(MaterialWarningSetting.is_enabled.is_(True), inv_val <= yellow_val), 1), + (and_(MaterialWarningSetting.is_enabled.is_(True), red_val.isnot(None), inv_val <= red_val), 2), + (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 ) - # 红色预警时的缺口 - 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( - desc(warning_level), - desc(red_shortage), - asc(yellow_distance), - desc(inv_val), + desc(warning_level), # 1. 先按红、黄、正常排 + desc(shortage), # 2. 同级别内,缺口越大的排越上面 + desc(inv_val), # 3. 缺口一样,库存多的排上面 desc(MaterialBase.id) ) elif order_by_column: @@ -462,12 +457,18 @@ class MaterialBaseService: 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'] - if invQty <= warning_red: + + # 优先判断红色预警(如果设置了红阈值,且库存 <= 红阈值) + if warning_red is not None and invQty <= warning_red: item_dict['warningStatus'] = 2 # 红色 + + # 其次判断黄色预警(如果设置了黄阈值,且库存 <= 黄阈值) elif warning_yellow is not None and invQty <= warning_yellow: item_dict['warningStatus'] = 1 # 黄色 + + # 都不满足则正常 else: item_dict['warningStatus'] = 0 # 正常 else: diff --git a/inventory-backend/app/services/inventory_task.py b/inventory-backend/app/services/inventory_task.py new file mode 100644 index 0000000..c2544bc --- /dev/null +++ b/inventory-backend/app/services/inventory_task.py @@ -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') + } diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index a222cf5..0814409 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -253,6 +253,13 @@ class OutboundService: ) 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: approval.status = 3 # 3-已完成 diff --git a/inventory-backend/app/utils/email_service.py b/inventory-backend/app/utils/email_service.py index b6c5b80..b333cf7 100644 --- a/inventory-backend/app/utils/email_service.py +++ b/inventory-backend/app/utils/email_service.py @@ -159,6 +159,12 @@ def send_new_request_notify(to_emails: List[str], request_no: str, 物料清单如下: {chr(10).join(rows)} +--- +⚡ 快速通道: +请点击下方链接直接进入系统审批: +https://172.16.0.198/outbound/approval +--- + 请登录仓库管理系统进行审批。 此邮件由系统自动发送,请勿回复。 diff --git a/inventory-web/src/api/material_base.ts b/inventory-web/src/api/material_base.ts index dd47e4e..b74d1c7 100644 --- a/inventory-web/src/api/material_base.ts +++ b/inventory-web/src/api/material_base.ts @@ -77,4 +77,13 @@ export function getLatestSpecs() { url: '/inbound/base/spec-latest', method: 'get' }) +} + +// 8. 标记预警物料已采购 +export function markWarningOrdered(data: { baseId: number; isOrdered: boolean }) { + return request({ + url: '/inbound/base/warning/mark-ordered', + method: 'post', + data + }) } \ No newline at end of file diff --git a/inventory-web/src/views/material/list.vue b/inventory-web/src/views/material/list.vue index d05be4a..02b5c5b 100644 --- a/inventory-web/src/views/material/list.vue +++ b/inventory-web/src/views/material/list.vue @@ -302,10 +302,14 @@ - + @@ -560,10 +564,16 @@
库存数量 ≤ 此值时显示红色预警
+ + +
红色阈值 < 库存 ≤ 此值时显示黄色预警
+ + +