From ccbce82c2e1088cc493a00e990ebda48fe114674 Mon Sep 17 00:00:00 2001 From: DXC Date: Tue, 28 Apr 2026 16:46:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(email):=20=E5=AE=A1=E6=89=B9=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=90=8E=E5=BA=93=E7=AE=A1=E9=80=9A=E7=9F=A5=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=98=8E=E7=BB=86+DEBUG=E6=97=A5=E5=BF=97=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DMAIL=5FDEFAULT=5FSENDER=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/outbound_service.py | 100 ++++++++++++---- inventory-backend/app/utils/email_service.py | 108 +++++++++++++++--- inventory-backend/config.py | 6 +- 3 files changed, 178 insertions(+), 36 deletions(-) diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index 0b01d4b..a158969 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -492,8 +492,6 @@ class OutboundService: ModelClass = model_map.get(d.source_table) if ModelClass and d.stock_id: - # 注意:这里在循环中查询可能会有N+1问题,但考虑到单页数据量(通常每单条目不多),暂时可接受 - # 生产环境建议优化为预加载或批量查询 try: stock_item = ModelClass.query.get(d.stock_id) if stock_item: @@ -716,12 +714,16 @@ class OutboundApprovalService: # username 格式为 "姓名/账号",取姓名部分 applicant_name = str(u.username).split('/')[0] if '/' in u.username else (u.username or str(applicant_id)) + # ★ 发送通知,附完整物料清单 + items = approval.get_items() send_new_request_notify( to_emails=emails, request_no=approval.request_no, applicant_name=applicant_name, - remark=approval.remark or '' + remark=approval.remark or '', + items=items ) + except Exception as e: # ★ 捕获所有异常,确保邮件发送失败不阻断主流程 try: @@ -729,6 +731,7 @@ class OutboundApprovalService: current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}") except RuntimeError: # 如果不在 Flask 应用上下文内,降级为标准日志 + import logging logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}") @staticmethod @@ -817,28 +820,85 @@ class OutboundApprovalService: @staticmethod def _notify_approval_result(approval, approver_id, action): """发送审批结果通知邮件(静默处理,不阻断主流程)""" + import logging + logger = logging.getLogger(__name__) + try: - from app.utils.email_service import send_approval_result_notify + from app.utils.email_service import send_approval_result_notify, send_warehouse_dispatch_notify - # 仓库管理员角色代码 - warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND'] + # ★ 通过:只通知库管,附完整物料清单 + if action == 'approve': + warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND'] - # 查询库管邮箱 + 申请人本人邮箱 - emails = OutboundApprovalService._get_emails_by_identifiers( - applicant_id=approval.applicant_id, - role_codes=warehouse_role_codes - ) - if not emails: - return + # --- 【DEBUG】精准打印调试信息 --- + print(f"[DEBUG] === _notify_approval_result 审批通过分支 ===") + print(f"[DEBUG] 审批单 ID={approval.id} request_no={approval.request_no}") + print(f"[DEBUG] items_json 原始值类型: {type(approval.items_json)} 值={repr(approval.items_json)}") + + # ★ items_json 已是 list,直接使用,不做 json.loads 解析 + items = approval.items_json if approval.items_json else [] + + # 查询库管邮箱 + emails = OutboundApprovalService._get_emails_by_identifiers( + role_codes=warehouse_role_codes + ) + print(f"[DEBUG] 库管邮箱列表: {emails}") + print(f"[DEBUG] 库管角色配置: {warehouse_role_codes}") + + # 打印所有 SysUser 中 role 属于这两个角色的邮箱(用于诊断) + from app.models.system import SysUser as SU + all_warehouse_users = SU.query.filter(SU.role.in_(warehouse_role_codes)).all() + print(f"[DEBUG] 数据库中库管角色用户数: {len(all_warehouse_users)}") + for u in all_warehouse_users: + print(f" -> 用户ID={u.id} role={u.role} email={u.email} username={u.username}") + + if not emails: + print(f"[DEBUG] 警告:无库管邮箱,跳过通知") + return + + # 获取申请人姓名 + applicant_name = '' + 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 '') + + print(f"[DEBUG] 准备发送邮件 to={emails} items数量={len(items)} items内容={items}") + + # ★ 调用库管备货通知(含完整物料明细表格) + send_warehouse_dispatch_notify( + to_emails=emails, + request_no=approval.request_no, + applicant_name=applicant_name, + items=items + ) + print(f"[DEBUG] send_warehouse_dispatch_notify 调用完成") + + # ★ 驳回:只通知申请人本人 + else: + from app.models.system import SysUser as SU2 + applicant_name = '' + if approval.applicant_id: + user = SU2.query.get(approval.applicant_id) + if user: + applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '') + emails = OutboundApprovalService._get_emails_by_identifiers( + applicant_id=approval.applicant_id + ) + if not emails: + return + send_approval_result_notify( + to_emails=emails, + request_no=approval.request_no, + is_passed=False, + reject_reason=approval.reject_reason or '', + applicant_name=applicant_name + ) - send_approval_result_notify( - to_emails=emails, - request_no=approval.request_no, - is_passed=(action == 'approve'), - reject_reason=approval.reject_reason or '' - ) except Exception as e: - logging.getLogger(__name__).warning(f"[Email] 发送审批结果通知邮件失败: {e}") + import traceback + traceback.print_exc() + logger.warning(f"[Email] 发送审批结果通知邮件失败: {e}") @staticmethod def get_request_list(page=1, per_page=10, applicant_id=None, status=None): diff --git a/inventory-backend/app/utils/email_service.py b/inventory-backend/app/utils/email_service.py index b0ac9e2..b9848d1 100644 --- a/inventory-backend/app/utils/email_service.py +++ b/inventory-backend/app/utils/email_service.py @@ -58,19 +58,24 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str): """ cfg = _get_config() + print(f"[DEBUG send_email] cfg = {cfg}") + # 发送总开关 if not cfg.get('enabled'): + print(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}") logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}") return # 配置完整性检查 if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'): + print(f"[Email] 邮件配置不完整 server={cfg.get('server')} username={cfg.get('username')} password={'已设' if cfg.get('password') else '空'},跳过发送") logger.warning("[Email] 邮件配置不完整 (MAIL_SERVER/USERNAME/PASSWORD 缺失),跳过发送") return # 标准化收件人列表 recipients = [to_email] if isinstance(to_email, str) else [r.strip() for r in to_email if r.strip()] if not recipients: + print("[Email] 收件人地址为空,跳过发送") logger.warning("[Email] 收件人地址为空,跳过发送") return @@ -81,6 +86,8 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str): msg['Subject'] = Header(subject, 'utf-8') msg.attach(MIMEText(content, 'plain', 'utf-8')) + print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}") + if cfg.get('use_ssl'): context = ssl.create_default_context() with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server: @@ -96,26 +103,50 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str): logger.info(f"[Email] 发送成功 -> {recipients}: {subject}") except smtplib.SMTPAuthenticationError: + print(f"!!! 邮件发送核心报错: SMTPAuthenticationError - 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)") logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)") except smtplib.SMTPRecipientsRefused as e: + print(f"!!! 邮件发送核心报错: SMTPRecipientsRefused - 收件人被服务器拒绝: {e}") logger.error(f"[Email] 收件人被服务器拒绝: {e}") except smtplib.SMTPException as e: + print(f"!!! 邮件发送核心报错: SMTPException - {e}") logger.error(f"[Email] SMTP 异常: {e}") except Exception as e: + import traceback + traceback.print_exc() + print(f"!!! 邮件发送核心报错: {type(e).__name__} - {e}") logger.error(f"[Email] 发送邮件时发生未知异常: {e}") def send_new_request_notify(to_emails: List[str], request_no: str, - applicant_name: str = '', remark: str = ''): + applicant_name: str = '', remark: str = '', + items: list = None): """ - 通知审批人有新的出库申请单待审批 + 通知审批人有新的出库申请单待审批(可附带物料清单) Args: to_emails: 审批人邮箱列表 request_no: 审批单号 applicant_name: 申请人姓名 remark: 申请备注 + items: 物料明细列表(可选) """ + print(f"[DEBUG send_new_request_notify] 入参 items={items}") + print(f"[DEBUG send_new_request_notify] items 类型={type(items)}, 长度={len(items) if items else 0}") + + # 拼装物料表格 + 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("(无物料明细)") + subject = f"【待审批】出库申请单 {request_no}" content = f"""您好, @@ -125,6 +156,9 @@ def send_new_request_notify(to_emails: List[str], request_no: str, 申请人:{applicant_name or '未知'} 备注说明:{remark or '无'} +物料清单如下: +{chr(10).join(rows)} + 请登录仓库管理系统进行审批。 此邮件由系统自动发送,请勿回复。 @@ -133,36 +167,84 @@ def send_new_request_notify(to_emails: List[str], request_no: str, def send_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 = ''): """ - 通知库管和申请人审批结果 + 通知审批结果 Args: - to_emails: 收件人邮箱列表(库管 + 申请人) - request_no: 审批单号 - is_passed: 是否通过 + to_emails: 收件人邮箱列表 + request_no: 审批单号 + is_passed: 是否通过(通过时发给库管,驳回时发给申请人) reject_reason: 驳回原因(仅 is_passed=False 时使用) + applicant_name: 申请人姓名(仅驳回通知时使用) """ if is_passed: - subject = f"【已通过】出库申请单 {request_no}" + # ★ 发给库管:提醒备货出库 + subject = f"【待出库】出库申请单 {request_no} 已审批通过" content = f"""您好, -出库申请单 {request_no} 已审批通过,请准备备货。 +出库申请单 {request_no} 已审批通过,请尽快备货出库。 -请尽快安排出库操作。 +请登录仓库管理系统处理该出库任务。 此邮件由系统自动发送,请勿回复。 """ else: + # ★ 发给申请人:告知被驳回 subject = f"【已驳回】出库申请单 {request_no}" - content = f"""您好, + content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"}, -出库申请单 {request_no} 已被驳回。 +出库申请单 {request_no} 已被审批驳回。 驳回原因:{reject_reason or '未填写'} -请登录仓库管理系统查看详情。 +请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。 此邮件由系统自动发送,请勿回复。 """ send_email(to_emails, subject, content) + + +def send_warehouse_dispatch_notify(to_emails: List[str], request_no: str, + 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_warehouse_dispatch_notify] items 类型={type(items)}, 长度={len(items) if items else 0}") + + 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) + print(f"DEBUG: 准备向服务器提交发信请求,收件人: {to_emails}") diff --git a/inventory-backend/config.py b/inventory-backend/config.py index 12b164a..7b37d16 100644 --- a/inventory-backend/config.py +++ b/inventory-backend/config.py @@ -65,7 +65,7 @@ class Config: MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() in ('true', '1', 'yes') # 是否启用 SSL (465 端口通常需要,阿里邮箱必须启用 SSL) MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'true').lower() in ('true', '1', 'yes') - # 默认发件人(显示名称 <邮箱地址>,阿里要求 MAIL_USERNAME 与此处邮箱地址完全一致) - MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'WMS系统 ') + # 默认发件人(★ 必须与 MAIL_USERNAME 完全一致,否则阿里邮件服务器会拒绝) + MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn') # 是否启用邮件发送功能(开发环境可设为 false 禁用) - MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes') \ No newline at end of file + MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')