feat(purchase): 新增「待采购清单」页面

路由 router/index.ts:
  /purchase 下新增子路由 pending(name: PurchasePendingPool)。
  ★ 父路由刻意不加 permissions —— 加了会让没有 pending_pool 权限的用户
    整个「采购管理」模块从侧边栏消失。

页面 views/purchase/PendingPool.vue:
  筛选栏 + 表格 + 分页,列含产品图、规格、当前库存、在途、有效供给、
  预警、建议采购量(tooltip 展示完整算式)、参考单价、采购链接、操作。
  采购链接渲染走 safeHref 白名单,非法地址不渲染成可点击链接。

api/purchase.ts:
  新增 getPendingPurchasePool 与 PendingPoolItem 类型。

★ 需告知业务方的可见变化:「采购管理」从扁平单项变为可展开子菜单
  (Sidebar 在子路由只有 1 个时渲染 el-menu-item,2 个时渲染 el-sub-menu)。
This commit is contained in:
yueli
2026-09-18 14:31:07 +08:00
parent b4445f07d9
commit 690ee2c81d
3 changed files with 509 additions and 0 deletions

View File

@ -163,3 +163,46 @@ export function getApprovedUnstockedRequests(params: {
params
})
}
// 待采购池的单条物料(防重复采购)
export interface PendingPoolItem {
material_id: number
name: string
spec_model: string
unit: string
category: string
material_type: string
company_name: string
image: string
purchase_link: string
reference_price: number | null
inventory_count: number
available_count: number
// 在途量 = 该物料所有活跃采购单的剩余待入库量之和
in_transit_qty: number
// 有效供给 = 库存 + 在途。判缺货与算建议量都基于它
effective_supply: number
warning_status: number // 0=正常 1=黄色 2=红色(进池的必然非 0
warning_red: number | null
warning_yellow: number | null
target_threshold: number | null
suggested_qty: number | null
is_ordered: boolean
}
// 待采购清单:库存已触及预警线、且没有活跃采购单的物料。
// 物料一旦有在途采购单(待审批/已通过/部分到货未齐)就会从这里消失。
export function getPendingPurchasePool(params: {
page?: number
limit?: number
keyword?: string
category?: string
type?: string
warning_status?: number
}) {
return request({
url: '/purchase/pending-pool',
method: 'get',
params
})
}

View File

@ -213,6 +213,8 @@ const routes: Array<RouteRecordRaw> = [
{
path: '/purchase',
component: Layout,
// ★ 父路由**不加** permissions加了会让没有 pending_pool 权限的用户
// 整个「采购管理」模块从侧边栏消失。
meta: { title: '采购管理', icon: 'ShoppingCart' },
children: [
{
@ -220,6 +222,12 @@ const routes: Array<RouteRecordRaw> = [
name: 'PurchaseList',
component: () => import('@/views/purchase/index.vue'),
meta: { title: '采购申请' }
},
{
path: 'pending',
name: 'PurchasePendingPool',
component: () => import('@/views/purchase/PendingPool.vue'),
meta: { title: '待采购清单', permissions: ['inbound_purchase:pending_pool'] }
}
]
},

View File

@ -0,0 +1,458 @@
<template>
<div class="app-container">
<el-alert
type="info"
:closable="false"
show-icon
style="margin-bottom: 14px;"
>
<template #title>
<b>有效供给</b>当前库存 + 在途量判断只有有效供给仍低于预警线的物料才会出现在这里
在途量取该物料所有活跃采购单待审批 / 已通过 / 部分到货未齐<b>剩余待入库量之和</b>
所以已经买过一部分的物料不会消失 它会继续留在清单里建议采购量只补剩下的缺口
</template>
</el-alert>
<!-- 顶部筛选 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item label="搜索">
<el-input
v-model="filters.keyword"
placeholder="物料名称 / 规格型号 / 公司"
style="width: 240px"
clearable
@keyup.enter="handleQuery"
@clear="handleQuery"
/>
</el-form-item>
<el-form-item label="类别">
<el-input
v-model="filters.category"
placeholder="类别前缀"
style="width: 180px"
clearable
@keyup.enter="handleQuery"
@clear="handleQuery"
/>
</el-form-item>
<el-form-item label="类型">
<el-input
v-model="filters.type"
placeholder="物料类型"
style="width: 150px"
clearable
@keyup.enter="handleQuery"
@clear="handleQuery"
/>
</el-form-item>
<el-form-item label="预警等级">
<el-radio-group v-model="filters.warningStatus" @change="handleQuery">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="2">红色</el-radio-button>
<el-radio-button :label="1">黄色</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Refresh" @click="handleQuery">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<el-button
v-if="userStore.hasPermission('inbound_purchase:operation')"
type="success"
:icon="ShoppingCart"
:disabled="selectedRows.length === 0"
@click="handleBatchGenerate"
>批量生成采购申请{{ selectedRows.length ? `${selectedRows.length}` : '' }}</el-button>
</el-form-item>
</el-form>
<el-table
v-loading="loading"
:data="list"
border
stripe
fit
style="width: 100%; margin-top: 16px;"
row-key="material_id"
empty-text="暂无待采购物料所有缺货物料都已有在途采购单"
@selection-change="handleSelectionChange"
>
<!-- 刻意不加 reserve-selection它会让勾选跨页保留但按钮上的数量
与实际可见的勾选状态可能不一致甚至保留已离开池子的物料
用户看到已选 5 而屏幕上一个勾都没有待采购池是小工作台
跨页批量的收益远小于这种静默不一致的代价 -->
<el-table-column type="selection" width="46" align="center" />
<el-table-column label="产品图" width="90" align="center">
<template #default="{ row }">
<el-image
v-if="row.image"
:src="getImageUrl(row.image)"
:preview-src-list="[getImageUrl(row.image)]"
preview-teleported
fit="cover"
style="width: 56px; height: 56px; border-radius: 4px;"
>
<template #error>
<div class="img-fallback">无图</div>
</template>
</el-image>
<span v-else style="color: #c0c4cc; font-size: 12px;">无图</span>
</template>
</el-table-column>
<el-table-column prop="name" label="物料名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.spec_model">{{ row.spec_model }}</span>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="80" align="center">
<template #default="{ row }">{{ row.unit || '-' }}</template>
</el-table-column>
<el-table-column prop="category" label="类别" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ row.category || '-' }}</template>
</el-table-column>
<el-table-column label="当前库存" width="100" align="right">
<template #default="{ row }">
<span class="money-text">{{ row.inventory_count }}</span>
</template>
</el-table-column>
<el-table-column label="在途" width="100" align="right">
<template #default="{ row }">
<el-tooltip
v-if="row.in_transit_qty > 0"
content="该物料所有活跃采购单的剩余待入库量之和"
placement="top"
>
<span class="in-transit">+{{ row.in_transit_qty }}</span>
</el-tooltip>
<span v-else style="color: #c0c4cc;">0</span>
</template>
</el-table-column>
<el-table-column label="有效供给" width="110" align="right">
<template #default="{ row }">
<el-tooltip
:content="`库存 ${row.inventory_count} + 在途 ${row.in_transit_qty} = ${row.effective_supply}(判缺货用的就是它)`"
placement="top"
>
<span class="effective">{{ row.effective_supply }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="可用库存" width="100" align="right">
<template #default="{ row }">
<span :class="{ 'avail-warn': row.available_count <= 0 }">
{{ row.available_count }}
</span>
</template>
</el-table-column>
<el-table-column label="预警" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.warning_status === 2" type="danger" size="small">红色预警</el-tag>
<el-tag v-else-if="row.warning_status === 1" type="warning" size="small">黄色预警</el-tag>
<span v-else style="color: #c0c4cc;">-</span>
<div style="font-size: 11px; color: #909399; margin-top: 2px;">
{{ fmt(row.warning_red) }} / {{ fmt(row.warning_yellow) }}
</div>
</template>
</el-table-column>
<el-table-column label="建议采购量" width="120" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.suggested_qty != null"
:content="suggestedTip(row)"
placement="top"
>
<span class="suggested">{{ row.suggested_qty }}</span>
</el-tooltip>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="参考单价" width="110" align="right">
<template #default="{ row }">
<span v-if="row.reference_price != null">{{ row.reference_price.toFixed(2) }}</span>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="采购链接" width="120" align="center">
<template #default="{ row }">
<!-- 必须过 safeHref 白名单地址由用户自由填写直接绑 href 会让
javascript: 之类的伪协议被当成可点击链接执行 -->
<el-link
v-if="safeHref(row.purchase_link)"
type="primary"
:href="safeHref(row.purchase_link)"
target="_blank"
rel="noopener noreferrer"
>🔗 前往采购</el-link>
<span v-else-if="row.purchase_link" style="color: #909399;" :title="row.purchase_link">格式无效</span>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="userStore.hasPermission('inbound_purchase:operation')"
type="primary"
size="small"
@click="handleGenerate(row)"
>生成采购申请</el-button>
<span v-else style="color: #c0c4cc; font-size: 12px;">无建单权限</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:background="true"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Refresh, ShoppingCart } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { getPendingPurchasePool, type PendingPoolItem } from '@/api/purchase'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const list = ref<PendingPoolItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedRows = ref<PendingPoolItem[]>([])
/**
* 批量交接数据走 localStorage而不是 URL query。
*
* 原因批量可能勾选十几个物料每个还带商家链接material_base.purchase_link
* 是 Text实测单条已 516 字符)。塞进 URL 会撞上长度上限并被**静默截断** ——
* 前端拿到半截 JSON 只会报解析失败排查成本很高。localStorage 没有这个上限。
* query 里只传一个一次性 key取完即删。
*/
const BATCH_KEY_PREFIX = 'MOM_PURCHASE_BATCH_'
const filters = reactive({
keyword: '',
category: '',
type: '',
warningStatus: '' as '' | 1 | 2
})
const fmt = (v: number | null) => (v == null ? '-' : v)
/**
* 建议采购量的说明文案。
*
* ★ 为什么要分两种:有效供给**恰好等于**阈值时,`阈值 - 有效供给` 是 0
* 但后端有 `max(1, ...)` 保底,结果是 1。若直接套「阈值 有效供给 = 建议量」
* 的模板页面上会出现「4 4 = 1」这种**算不对的式子** —— 采购员一旦发现
* 算式不成立,就不会再信这个数字。所以到线的情况单独讲清楚。
*
* 等于阈值仍算不足,是与物料列表预警、预警邮件一致的口径(见后端
* purchase_service.get_pending_purchase_pool 的说明)。
*/
const suggestedTip = (row: PendingPoolItem) => {
const target = row.target_threshold
const eff = row.effective_supply
if (target != null && target - eff >= 1) {
return `补至 ${target}:目标阈值 ${target} 有效供给 ${eff} = ${row.suggested_qty}(在途已计入,不会重复买)`
}
return `有效供给 ${eff} 已达阈值线 ${target},建议补 ${row.suggested_qty} 个作缓冲。` +
`(等于阈值仍算不足 —— 与物料列表的预警口径一致)`
}
const getImageUrl = (url: string) => (!url ? '' : url)
/** 采购链接白名单:只放行 http/https其余一律不渲染成链接 */
const safeHref = (url: any): string | null => {
const s = String(url || '').trim()
return /^https?:\/\//i.test(s) ? s : null
}
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
limit: pageSize.value
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.category) params.category = filters.category
if (filters.type) params.type = filters.type
if (filters.warningStatus !== '') params.warning_status = filters.warningStatus
const res: any = await getPendingPurchasePool(params)
const data = res?.data ?? {}
list.value = data.items || []
total.value = data.total || 0
} catch (e: any) {
ElMessage.error(e?.msg || '加载待采购清单失败')
list.value = []
total.value = 0
} finally {
loading.value = false
}
}
const handleQuery = () => {
page.value = 1
fetchData()
}
const resetFilter = () => {
filters.keyword = ''
filters.category = ''
filters.type = ''
filters.warningStatus = ''
page.value = 1
fetchData()
}
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
/**
* 生成采购申请:新标签页打开采购申请页并自动回填。
*
* 用 window.open 而非 router.push 是刻意的 —— 待采购清单的工作流是**连续建单**
* 同标签跳转会卸载本页,采购员每开一单都要点回来重新筛选、丢失页码;
* 新标签页保留列表状态。这与「基础信息」页既有的跳转方式也保持一致。
*/
const handleSelectionChange = (rows: PendingPoolItem[]) => {
selectedRows.value = rows || []
}
/** 把池中一行整理成弹窗明细行的预填数据(单行与批量共用同一套字段映射) */
const toPrefillItem = (row: PendingPoolItem) => ({
materialId: row.material_id,
name: row.name || '',
spec: row.spec_model || '',
// 建议采购量作为默认数量带入
quantity: row.suggested_qty != null && row.suggested_qty > 0 ? row.suggested_qty : undefined,
// 供应商链接(基础表的采购链接)带入,采购员无需再去查
supplierLink: String(row.purchase_link || '').trim() || undefined
})
/**
* 把预填数据交给采购申请页,开新标签页。
*
* ★ 单行与批量都走这里、都经 localStorage 中转 —— 曾经单行走 URL query
* 而 material_base.purchase_link 是 text实测已有 516 字符的真实链接),
* URL 长度受限会逼出一个「多长就不带」的硬编码阈值,导致同一个物料
* 单行跳转带链接、批量跳转不带,行为不一致。现在两条路径统一,无长度上限。
*
* 开新标签页是刻意的:待采购清单的工作流是连续建单,同标签跳转会卸载本页、
* 丢失筛选与勾选状态。
*/
const openPurchaseWithItems = (items: ReturnType<typeof toPrefillItem>[]) => {
const key = BATCH_KEY_PREFIX + Date.now()
try {
localStorage.setItem(key, JSON.stringify(items))
} catch (e) {
ElMessage.error('暂存待建单数据失败,请重试')
return
}
const routeUrl = router.resolve({ path: '/purchase', query: { batch_key: key } })
window.open(routeUrl.href, '_blank')
}
/** 单行:生成采购申请 */
const handleGenerate = (row: PendingPoolItem) => {
openPurchaseWithItems([toPrefillItem(row)])
}
/** 批量:勾选 N 个物料 → 一次性铺成 N 行,一单多品合并采购 */
const handleBatchGenerate = () => {
if (selectedRows.value.length === 0) return
openPurchaseWithItems(selectedRows.value.map(toPrefillItem))
}
onMounted(fetchData)
</script>
<style scoped>
.app-container { padding: 20px; }
/* 搜索区版式:与采购申请 / 出库记录 / 报废记录保持一致 */
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.suggested {
font-weight: 600;
color: #409eff;
}
/* 在途量与有效供给:本次改造新增的核心数字,用不同色区分于纯库存 */
.in-transit {
color: #e6a23c;
font-weight: 600;
}
.effective {
font-weight: 600;
border-bottom: 1px dashed #c0c4cc;
cursor: help;
}
.avail-warn {
color: #f56c6c;
font-weight: 600;
}
.img-fallback {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
color: #c0c4cc;
font-size: 12px;
border-radius: 4px;
}
</style>