feat: 采购模块重构 — 权限驱动的可见性 + 严格操作权限

## purchase.py
- GET /purchase (列表): @jwt_required, 有权限看同公司全部/无权限只看自己
- POST /purchase (创建): @jwt_required, 任何登录用户可提交
- GET /purchase/<id> (详情): @jwt_required, 自己的或有权者可见
- PATCH /purchase/<id>/approve (审批): @permission_required('inbound_purchase:operation')
  不再硬编码 SUPERVISOR 角色, 有操作权限即可审批
- GET /purchase/approvers, /auto-fill, /search-material: @jwt_required (开放给创建流程)
- 新增 _user_has_purchase_perm() 统一权限检查
- 移除所有硬编码 role in ('SUPERVISOR','SUPER_ADMIN')

## purchase_service.py
- send_email → send_email_async (采购审批邮件异步发送)

## purchase/index.vue
- canApprove: 仅检查 inbound_purchase:operation (菜单权限≠审批权限)
- customUpload: 修复 v-model:file-list 重复推入导致图片双份显示
- auth.py → purchase.py 权限前缀统一
This commit is contained in:
yueli
2026-07-16 11:25:53 +08:00
parent 0b75740ae0
commit 128985af17
3 changed files with 187 additions and 145 deletions

View File

@ -24,12 +24,12 @@
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="80" align="center" />
<el-table-column prop="purchase_date" label="采购日期" width="110" />
<el-table-column label="含税单价" width="110" align="right">
<el-table-column label="含税单价" width="110" align="right">
<template #default="{ row }">
{{ row.unit_price ? '¥' + Number(row.unit_price).toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column label="含税总价" width="120" align="right">
<el-table-column label="含税总价" width="120" align="right">
<template #default="{ row }">
{{ row.total_price ? '¥' + Number(row.total_price).toFixed(2) : '-' }}
</template>
@ -71,114 +71,116 @@
/>
<!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form ref="formRef" :model="form" label-width="110px">
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="750px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form ref="formRef" :model="form" label-width="100px">
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="采购物品" required>
<el-select
v-model="materialBaseId"
filterable
remote
reserve-keyword="true"
clearable
placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option="true"
popper-class="long-dropdown"
v-loadmore="handleLoadMoreMaterials"
@visible-change="onMaterialDropdownVisibleChange"
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
<!-- 物料信息 -->
<div class="form-section">
<div class="section-title"><span>物料信息</span></div>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="采购物品" required>
<el-select
v-model="materialBaseId" filterable remote reserve-keyword="true" clearable
placeholder="输入名称或规格搜索..." :remote-method="handleSearchMaterialDebounced"
:loading="searchLoading" style="width: 100%" @change="onMaterialSelected"
default-first-option="true" popper-class="long-dropdown"
v-loadmore="handleLoadMoreMaterials" @visible-change="onMaterialDropdownVisibleChange"
>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px;">{{ item.spec_model || '-' }}</span>
</div>
</el-option>
<li v-if="loadingMore" class="el-select-dropdown__item loading-more" style="text-align:center;color:#999;cursor:default;"><span>加载中...</span></li>
</el-select>
<div v-if="autoFillHint" style="font-size: 12px; color: #67C23A; margin-top: 4px;">{{ autoFillHint }}</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="规格型号">
<el-input v-model="form.spec_model" placeholder="自动填充" clearable />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="采购数量" required>
<el-input-number v-model="form.quantity" :min="1" :controls="false" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="采购日期" required>
<el-date-picker v-model="form.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 价格与审批 -->
<div class="form-section">
<div class="section-title"><span>价格与审批</span></div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="含税单价">
<el-input-number v-model="form.unit_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="onUnitPriceChange" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="含税总价">
<el-input-number v-model="form.total_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="onTotalPriceChange" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="税率">
<el-select v-model="form.tax_rate" style="width: 100%;" @change="onTaxRateChange">
<el-option label="0%" :value="0" />
<el-option label="1%" :value="1" />
<el-option label="6%" :value="6" />
<el-option label="13%" :value="13" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="审批人" required>
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width: 100%;" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商家链接">
<el-input v-model="form.supplier_link" placeholder="https://" clearable />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 备注与图片 -->
<div class="form-section">
<div class="section-title"><span>备注与凭证</span></div>
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(选填)" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="图片凭证" required id="upload-purchase-images">
<el-upload
v-model:file-list="fileList"
:http-request="customUpload"
:on-remove="handleRemoveImage"
:before-upload="beforeAvatarUpload"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
list-type="picture-card"
>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px;">{{ item.spec_model || '-' }}</span>
</div>
</el-option>
<li v-if="loadingMore" class="el-select-dropdown__item loading-more" style="text-align:center;color:#999;cursor:default;">
<span>加载中...</span>
</li>
</el-select>
<div v-if="autoFillHint" style="font-size: 12px; color: #67C23A; margin-top: 4px;">{{ autoFillHint }}</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号">
<el-input v-model="form.spec_model" placeholder="从物料列表选择后自动填充" clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购数量" required>
<el-input-number v-model="form.quantity" :min="1" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购日期" required>
<el-date-picker v-model="form.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="审批人" required>
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width: 100%;" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="不含税单价">
<el-input-number v-model="form.unit_price" :min="0" :precision="2" style="width: 100%;" @change="onUnitPriceChange" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="不含税总价">
<el-input-number v-model="form.total_price" :min="0" :precision="2" style="width: 100%;" @change="onTotalPriceChange" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="税率">
<el-select v-model="form.tax_rate" style="width: 100%;" @change="onTaxRateChange">
<el-option label="0%" :value="0" />
<el-option label="1%" :value="1" />
<el-option label="6%" :value="6" />
<el-option label="13%" :value="13" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商家链接">
<el-input v-model="form.supplier_link" placeholder="商家地址链接" clearable />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(选填)" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="图片上传" required id="upload-purchase-images">
<el-upload
v-model:file-list="fileList"
:http-request="customUpload"
:on-remove="handleRemoveImage"
:before-upload="beforeAvatarUpload"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
list-type="picture-card"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<template #footer>
@ -334,8 +336,10 @@ const form = ref({
})
// --- 计算属性 ---
// 审批按钮仅对拥有操作权限的用户可见(菜单权限不等于审批权限)
const canApprove = computed(() => {
return userStore.role === 'SUPER_ADMIN' || userStore.role === 'SUPERVISOR'
return userStore.role === 'SUPER_ADMIN'
|| userStore.hasPermission('inbound_purchase:operation')
})
// --- 工具函数 ---
@ -558,9 +562,9 @@ const customUpload = async (options: any) => {
if (res.code === 200) {
const newUrl = res.data.url
form.value.images!.push(newUrl)
// 同步更新 fileList,触发 el-upload UI 刷新
fileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
onSuccess(res)
// ★ v-model:file-list 已自动管理列表onSuccess 通知 el-upload 更新状态即可
// 传入 { url } 让 el-upload 正确设置文件预览 URL
onSuccess({ url: getImageUrl(newUrl) })
} else {
ElMessage.error(res.msg || '上传失败')
onError(new Error(res.msg))
@ -682,4 +686,35 @@ onMounted(() => {
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* 表单分组 */
.form-section {
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px 16px 4px;
margin-bottom: 14px;
}
.form-section .section-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.form-section .section-title span {
position: relative;
padding-left: 10px;
}
.form-section .section-title span::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
background: #409EFF;
border-radius: 2px;
}
</style>