4.28
This commit is contained in:
@ -2,7 +2,9 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)"
|
||||
"Bash(git commit *)",
|
||||
"Bash(git *)",
|
||||
"Bash(del *)"
|
||||
]
|
||||
},
|
||||
"$version": 3
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
168
inventory-backend/app/utils/email_service.py
Normal file
168
inventory-backend/app/utils/email_service.py
Normal 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)
|
||||
375
inventory-web/src/views/outbound/approval/index.vue
Normal file
375
inventory-web/src/views/outbound/approval/index.vue
Normal 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
35
query_audit.py
Normal 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
38
upload_odoo_files.sh
Executable 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
108
图像信息导入.py
Executable 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()
|
||||
Reference in New Issue
Block a user