This commit is contained in:
dxc
2026-04-28 16:07:11 +08:00
parent 62c0e3738e
commit 183b93012e
8 changed files with 730 additions and 4 deletions

View File

@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)"
"Bash(git commit *)",
"Bash(git *)",
"Bash(del *)"
]
},
"$version": 3

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,375 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="list"
border
stripe
style="margin-top: 16px;"
row-key="id"
:expand-row-keys="expandedRows"
@expand-change="handleExpandChange"
>
<!-- 展开行 -->
<el-table-column type="expand" width="60" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
物料明细 {{ row.items?.length || 0 }}
</p>
<el-table
v-if="row.items?.length"
:data="row.items"
border
size="small"
style="width: 100%;"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row: item }">
<el-tag size="small">{{ item.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="计划数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #F56C6C; font-weight: bold;">{{ item.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无物料明细" :image-size="60" />
</div>
</template>
</el-table-column>
<el-table-column prop="request_no" label="申请单号" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
{{ row.request_no }}
</el-link>
</template>
</el-table-column>
<el-table-column label="申请人" width="140">
<template #default="{ row }">
{{ getApplicantName(row.applicant_id) }}
</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="物料种类" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.items?.length || 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="审批信息" width="180">
<template #default="{ row }">
<template v-if="row.status === 1">
<span style="color: #67C23A;">{{ getApproverName(row.actual_approver_id) }}</span>
<br />
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
</template>
<template v-else-if="row.status === 2">
<span style="color: #F56C6C;">已驳回</span>
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
</el-tooltip>
</template>
<template v-else-if="row.status === 3">
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<template v-if="row.status === 0">
<el-button
v-if="userStore.hasPermission('outbound_approval:operation')"
type="success"
size="small"
:loading="row._approving"
@click="handleApprove(row)"
>
通过
</el-button>
<el-button
v-if="userStore.hasPermission('outbound_approval:operation')"
type="danger"
size="small"
:loading="row._approving"
@click="openRejectDialog(row)"
>
驳回
</el-button>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- 驳回原因 Dialog -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请填写驳回原因(必填)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getApprovalRequestList, approveRequest } from '@/api/outbound'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const expandedRows = ref<string[]>([])
// 驳回 Dialog
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
const rejectLoading = ref(false)
// 申请人 / 审批人名称缓存(避免重复查询)
const userNameCache = ref<Record<number, string>>({})
// --- 工具函数 ---
const statusText = (status: number) => {
const map: Record<number, string> = {
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
}
return map[status] ?? '-'
}
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
}
return map[status] ?? 'info'
}
const getApplicantName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
const getApproverName = (id: number | null) => {
if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}`
}
// --- 展开行 ---
const toggleExpand = (row: any) => {
const idx = expandedRows.value.indexOf(row.id)
if (idx > -1) {
expandedRows.value.splice(idx, 1)
} else {
expandedRows.value.push(row.id)
}
}
const handleExpandChange = () => {
// expand 状态由 expandedRows 响应式控制,无需额外处理
}
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
limit: pageSize.value
}
if (filterStatus.value !== '') {
params.status = filterStatus.value
}
const res: any = await getApprovalRequestList(params)
// 追加申请人名称缓存
const records = res.data?.items || []
records.forEach((r: any) => {
if (r.applicant_id && !userNameCache.value[r.applicant_id]) {
// 后端已返回 applicant_name 字段时直接用,否则标记待解析
if (r.applicant_name) {
userNameCache.value[r.applicant_id] = r.applicant_name
}
}
if (r.actual_approver_id && !userNameCache.value[r.actual_approver_id]) {
if (r.approver_name) {
userNameCache.value[r.actual_approver_id] = r.approver_name
}
}
// 附加空标记,防止重复请求
r._approving = false
})
list.value = records
total.value = res.data?.total || records.length || 0
} catch (err: any) {
ElMessage.error(err?.msg || '加载审批列表失败')
} finally {
loading.value = false
}
}
// --- 筛选 ---
const handleStatusChange = () => {
page.value = 1
expandedRows.value = []
fetchData()
}
// --- 分页 ---
const handlePageChange = (p: number) => {
page.value = p
fetchData()
}
const handleSizeChange = (s: number) => {
pageSize.value = s
page.value = 1
fetchData()
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要通过出库申请单 【${row.request_no}】 吗?`,
'审批确认',
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' }
)
} catch {
return
}
row._approving = true
try {
await approveRequest(row.id, { action: 'approve' })
ElMessage.success(`申请单 ${row.request_no} 已通过`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '审批操作失败')
} finally {
row._approving = false
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
const reason = rejectReason.value.trim()
if (!reason) {
ElMessage.warning('请填写驳回原因')
return
}
rejectLoading.value = true
try {
await approveRequest(currentRejectRow.value.id, {
action: 'reject',
reject_reason: reason
})
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '驳回操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.app-container {
padding: 20px;
}
.filter-container {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
</style>

35
query_audit.py Normal file
View File

@ -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}')

38
upload_odoo_files.sh Executable file
View File

@ -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 "✅ 图像及附件物理转移全部完成!线上存储内容已更新。"

108
图像信息导入.py Executable file
View File

@ -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()