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:
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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'] }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
458
inventory-web/src/views/purchase/PendingPool.vue
Normal file
458
inventory-web/src/views/purchase/PendingPool.vue
Normal 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>
|
||||
Reference in New Issue
Block a user