diff --git a/.qwen/settings.json b/.qwen/settings.json index d0e48be..957c89d 100644 --- a/.qwen/settings.json +++ b/.qwen/settings.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "Bash(git add *)", - "Bash(git commit *)" + "Bash(git commit *)", + "Bash(git *)", + "Bash(del *)" ] }, "$version": 3 diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 347023d..1296e98 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -863,8 +863,8 @@ def export_stocktake(): user = SysUser.query.get(int(user_id)) if not user: user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first() - if not user: - user = SysUser.query.filter_by(username=str(user_id)).first() + # 注意:此处不再 fallback filter_by(username=...), + # 避免 PostgreSQL 将 user_id 数字与 username 字符串列做类型比较导致报错 if not user: return str(user_id) diff --git a/inventory-backend/app/models/__init__.py b/inventory-backend/app/models/__init__.py index 6c412fc..f246b5a 100644 --- a/inventory-backend/app/models/__init__.py +++ b/inventory-backend/app/models/__init__.py @@ -14,6 +14,6 @@ except ImportError: # 4. 出库记录 (如果有,BuyService 用到了 TransOutbound) try: - from app.models.outbound import TransOutbound + from app.models.outbound import TransOutbound, OutboundApproval except ImportError: pass \ No newline at end of file diff --git a/inventory-backend/app/utils/email_service.py b/inventory-backend/app/utils/email_service.py new file mode 100644 index 0000000..b0ac9e2 --- /dev/null +++ b/inventory-backend/app/utils/email_service.py @@ -0,0 +1,168 @@ +""" +邮件通知服务 +使用 Python smtplib + email.mime 实现,支持 TLS/SSL SMTP 连接 +从环境变量或 Flask config 读取邮件配置 +""" +import os +import smtplib +import ssl +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.header import Header +from typing import List, Union + +logger = logging.getLogger(__name__) + + +def _get_config(): + """ + 读取邮件配置,优先从 Flask app config,回退到环境变量 + """ + try: + from flask import current_app + return { + 'server': current_app.config.get('MAIL_SERVER', os.getenv('MAIL_SERVER')), + 'port': current_app.config.get('MAIL_PORT', int(os.getenv('MAIL_PORT', 587))), + 'username': current_app.config.get('MAIL_USERNAME', os.getenv('MAIL_USERNAME')), + 'password': current_app.config.get('MAIL_PASSWORD', os.getenv('MAIL_PASSWORD')), + 'sender': current_app.config.get('MAIL_DEFAULT_SENDER', os.getenv('MAIL_DEFAULT_SENDER')), + 'use_tls': current_app.config.get('MAIL_USE_TLS', os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes')), + 'use_ssl': current_app.config.get('MAIL_USE_SSL', os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes')), + 'enabled': current_app.config.get('MAIL_ENABLED', os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes')), + } + except RuntimeError: + # 不在 Flask 上下文时,直接读环境变量 + return { + 'server': os.getenv('MAIL_SERVER'), + 'port': int(os.getenv('MAIL_PORT', 587)), + 'username': os.getenv('MAIL_USERNAME'), + 'password': os.getenv('MAIL_PASSWORD'), + 'sender': os.getenv('MAIL_DEFAULT_SENDER'), + 'use_tls': os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes'), + 'use_ssl': os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes'), + 'enabled': os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes'), + } + + +def send_email(to_email: Union[str, List[str]], subject: str, content: str): + """ + 通用邮件发送函数 + + Args: + to_email: 收件人,单个邮箱字符串或列表 + subject: 邮件主题 + content: 邮件正文(纯文本) + + 发送失败时打印日志,不抛出异常 + """ + cfg = _get_config() + + # 发送总开关 + if not cfg.get('enabled'): + logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}") + return + + # 配置完整性检查 + if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'): + 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: + logger.warning("[Email] 收件人地址为空,跳过发送") + return + + try: + msg = MIMEMultipart() + msg['From'] = cfg['sender'] + msg['To'] = ', '.join(recipients) + msg['Subject'] = Header(subject, 'utf-8') + msg.attach(MIMEText(content, 'plain', 'utf-8')) + + if cfg.get('use_ssl'): + context = ssl.create_default_context() + with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server: + server.login(cfg['username'], cfg['password']) + server.sendmail(cfg['username'], recipients, msg.as_string()) + else: + with smtplib.SMTP(cfg['server'], cfg.get('port', 587)) as server: + if cfg.get('use_tls'): + server.starttls(context=ssl.create_default_context()) + server.login(cfg['username'], cfg['password']) + server.sendmail(cfg['username'], recipients, msg.as_string()) + + logger.info(f"[Email] 发送成功 -> {recipients}: {subject}") + + except smtplib.SMTPAuthenticationError: + logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)") + except smtplib.SMTPRecipientsRefused as e: + logger.error(f"[Email] 收件人被服务器拒绝: {e}") + except smtplib.SMTPException as e: + logger.error(f"[Email] SMTP 异常: {e}") + except Exception as e: + logger.error(f"[Email] 发送邮件时发生未知异常: {e}") + + +def send_new_request_notify(to_emails: List[str], request_no: str, + applicant_name: str = '', remark: str = ''): + """ + 通知审批人有新的出库申请单待审批 + + Args: + to_emails: 审批人邮箱列表 + request_no: 审批单号 + applicant_name: 申请人姓名 + remark: 申请备注 + """ + subject = f"【待审批】出库申请单 {request_no}" + content = f"""您好, + +您有一笔新的出库审批申请待处理: + + 申请单号:{request_no} + 申请人:{applicant_name or '未知'} + 备注说明:{remark or '无'} + +请登录仓库管理系统进行审批。 + +此邮件由系统自动发送,请勿回复。 +""" + send_email(to_emails, subject, content) + + +def send_approval_result_notify(to_emails: List[str], request_no: str, + is_passed: bool, reject_reason: str = ''): + """ + 通知库管和申请人审批结果 + + Args: + to_emails: 收件人邮箱列表(库管 + 申请人) + request_no: 审批单号 + is_passed: 是否通过 + reject_reason: 驳回原因(仅 is_passed=False 时使用) + """ + if is_passed: + subject = f"【已通过】出库申请单 {request_no}" + content = f"""您好, + +出库申请单 {request_no} 已审批通过,请准备备货。 + +请尽快安排出库操作。 + +此邮件由系统自动发送,请勿回复。 +""" + else: + subject = f"【已驳回】出库申请单 {request_no}" + content = f"""您好, + +出库申请单 {request_no} 已被驳回。 + +驳回原因:{reject_reason or '未填写'} + +请登录仓库管理系统查看详情。 + +此邮件由系统自动发送,请勿回复。 +""" + send_email(to_emails, subject, content) diff --git a/inventory-web/src/views/outbound/approval/index.vue b/inventory-web/src/views/outbound/approval/index.vue new file mode 100644 index 0000000..bfc3366 --- /dev/null +++ b/inventory-web/src/views/outbound/approval/index.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/query_audit.py b/query_audit.py new file mode 100644 index 0000000..20d82cf --- /dev/null +++ b/query_audit.py @@ -0,0 +1,35 @@ +import psycopg2 +import json + +try: + conn = psycopg2.connect( + host='localhost', + port=5432, + database='inventory_system', + user='test', + password='1234' + ) + cur = conn.cursor() + cur.execute('SELECT id, action, target_name, details FROM audit_logs ORDER BY id DESC LIMIT 3') + rows = cur.fetchall() + print('=== 最新3条审计日志 ===') + for row in rows: + print(f'ID: {row[0]}') + print(f'Action: {row[1]}') + print(f'Target: {row[2]}') + details = row[3] + if details: + # 格式化显示 + if isinstance(details, str): + try: + details = json.loads(details) + except: + pass + print(f'Details: {json.dumps(details, indent=2, ensure_ascii=False)}') + else: + print(f'Details: None') + print('---') + cur.close() + conn.close() +except Exception as e: + print(f'Error: {e}') \ No newline at end of file diff --git a/upload_odoo_files.sh b/upload_odoo_files.sh new file mode 100755 index 0000000..bc0e891 --- /dev/null +++ b/upload_odoo_files.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# === 配置项 === +SERVER="dxc@172.16.0.198" +LOCAL_DIR="Odoo_Archive" +REMOTE_TARGET_DIR="/opt/inventory-app/uploads_prod" +ARCHIVE_NAME="odoo_images_upload.tar.gz" + +echo "🚀 开始将本地图像及附件同步至线上存储目录..." + +# 1. 检查本地文件夹 +if [ ! -d "$LOCAL_DIR" ]; then + echo "❌ 找不到本地文件夹 $LOCAL_DIR,请确保脚本与该文件夹在同一层级!" + exit 1 +fi + +# 2. 本地打包 (使用 -C 保证解压后没有多余的外层文件夹) +echo "[1/4] 正在本地打包所有图片和文件..." +tar -czf $ARCHIVE_NAME -C $LOCAL_DIR . + +# 3. 传输到生产环境的 /tmp 目录 +echo "[2/4] 正在传输到服务器临时目录 /tmp (可能需要输入服务器密码)..." +scp $ARCHIVE_NAME $SERVER:/tmp/$ARCHIVE_NAME + +# 4. 服务器解压并设置权限 (核心:纯文件覆盖/追加,不碰数据库) +echo "[3/4] 正在远端部署图像到目标文件夹 (可能需要输入 sudo 密码)..." +ssh -t $SERVER "sudo mkdir -p $REMOTE_TARGET_DIR && \ + echo '>> 正在将图像释放到 $REMOTE_TARGET_DIR ...' && \ + sudo tar -xzf /tmp/$ARCHIVE_NAME -C $REMOTE_TARGET_DIR && \ + echo '>> 正在重置文件读写权限,确保线上服务可以正常显示图片...' && \ + sudo chmod -R 755 $REMOTE_TARGET_DIR && \ + sudo rm /tmp/$ARCHIVE_NAME" + +# 5. 清理本地压缩包 +echo "[4/4] 正在清理本地临时文件..." +rm $ARCHIVE_NAME + +echo "✅ 图像及附件物理转移全部完成!线上存储内容已更新。" \ No newline at end of file diff --git a/图像信息导入.py b/图像信息导入.py new file mode 100755 index 0000000..46bfc82 --- /dev/null +++ b/图像信息导入.py @@ -0,0 +1,108 @@ +import pandas as pd +import psycopg2 +import json +import os + +# ================= 配置区 ================= +DB_CONFIG = { + 'dbname': 'inventory_system', + 'user': 'test', + 'password': '1234', + 'host': 'localhost', + 'port': '5435' +} + +EXCEL_FILE = "Odoo_Archive/Odoo产品_终极大满贯版.xlsx" + + +# ================= 辅助函数 ================= +def process_paths_only(json_str): + """ + 将爬虫的绝对路径,转换为现有后端接口完美支持的纯文件名格式! + """ + if not json_str or str(json_str).strip() in ['[]', 'nan', 'None']: + return '[]' + + try: + paths = json.loads(json_str) + new_paths = [] + + for path in paths: + if path.startswith('http://') or path.startswith('https://'): + new_paths.append(path) + else: + filename = os.path.basename(path) + + # 【终极修复】去掉中间的子文件夹,直接请求文件名! + web_path = f"/api/v1/common/files/{filename}" + new_paths.append(web_path) + + return json.dumps(new_paths, ensure_ascii=False) + + except Exception as e: + return '[]' + + +# ================= 主程序 ================= +def process_excel_to_db(): + if not os.path.exists(EXCEL_FILE): + print(f"❌ 找不到 Excel 文件: {EXCEL_FILE}") + return + + try: + df = pd.read_excel(EXCEL_FILE, dtype=str) + df = df.where(pd.notnull(df), None) + print(f"✅ 成功读取 Excel,共 {len(df)} 行数据。") + + conn = psycopg2.connect(**DB_CONFIG) + cur = conn.cursor() + success_count = 0 + + for index, row in df.iterrows(): + internal_ref = row.get('内部参考') + barcode = row.get('条码') + + spec_model = "" + if barcode and internal_ref: + spec_model = f"{barcode}/{internal_ref}" + elif barcode: + spec_model = f"{barcode}" + elif internal_ref: + spec_model = f"{internal_ref}" + else: + continue + + raw_image_json = row.get('generalImage') + raw_manual_json = row.get('generalManual') + + if (not raw_image_json or raw_image_json == '[]') and (not raw_manual_json or raw_manual_json == '[]'): + continue + + product_image = process_paths_only(raw_image_json) + manual_link = process_paths_only(raw_manual_json) + + update_query = """ + UPDATE material_base + SET product_image = %s, \ + manual_link = %s + WHERE spec_model = %s + """ + cur.execute(update_query, (product_image, manual_link, spec_model)) + + if cur.rowcount > 0: + success_count += 1 + + conn.commit() + print(f"\n🎉 导入完成!成功更新了 {success_count} 条数据的正确路径。") + print("💡 赶快去刷新前端看看吧!这次图片一定能刷出来!") + + except Exception as e: + print(f"❌ 发生致命错误: {e}") + if 'conn' in locals() and conn: conn.rollback() + finally: + if 'cur' in locals() and cur: cur.close() + if 'conn' in locals() and conn: conn.close() + + +if __name__ == "__main__": + process_excel_to_db() \ No newline at end of file