feat: 打通采购申请与入库的按单入库链路

后端变更:
- PurchaseRequest 新增 base_id 硬关联 MaterialBase,StockBuy 新增 request_id 关联采购单
- handle_inbound 支持 request_id 入参,入库后自动反写采购单状态为已完成
- 新增 get_approved_requests 接口,返回已审批未入库采购单列表(含物料信息+历史数据回退匹配)
- create_purchase_request 新增 name+spec_model 自动匹配 MaterialBase
- 新增 SQL 和 Alembic 数据库迁移脚本

前端变更:
- 入库表单新增"从采购单导入"弹窗,支持搜索/选中已审批采购单并一键填充物料和商务信息
- 单价/总价交叉推算,缺项自动补全
- 选中行蓝色高亮+点击整行选中,行级交互优化
This commit is contained in:
yueli
2026-07-14 15:25:00 +08:00
parent 88b754120b
commit a11b7972c3
9 changed files with 643 additions and 16 deletions

View File

@ -3,6 +3,7 @@ import request from '@/utils/request'
export interface PurchaseItem {
id?: number
request_no?: string
base_id?: number
name: string
spec_model?: string
quantity: number
@ -22,6 +23,17 @@ export interface PurchaseItem {
reject_reason?: string
created_at?: string
updated_at?: string
// [新增] 关联物料基础信息(已审批未入库列表返回)
material?: {
id: number
company_name: string
name: string
spec_model: string
category: string
unit: string
type: string
is_inspection_required: boolean
} | null
}
export interface Approver {
@ -98,3 +110,16 @@ export function searchMaterialPurchase(keyword: string, page: number = 1) {
params: { keyword, page }
})
}
// [新增] 获取已审批通过且未入库的采购单列表(供库管按单入库使用)
export function getApprovedUnstockedRequests(params: {
page?: number
limit?: number
keyword?: string
}) {
return request({
url: '/purchase/approved-unstocked',
method: 'get',
params
})
}

View File

@ -280,6 +280,18 @@
<el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
<!-- [新增] 从采购单导入按钮(仅新增模式显示) -->
<el-button
v-if="dialogStatus === 'create'"
type="success"
plain
size="small"
style="margin-left: 15px;"
@click="openPurchaseImport"
>
<el-icon><Download /></el-icon> 从采购单导入
</el-button>
<el-link
v-if="form.base_id"
type="primary"
@ -292,6 +304,15 @@
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</div>
<!-- [新增] 已关联采购单提示 -->
<div v-if="form.request_id" style="padding: 8px 20px; background: #f0f9eb; border-bottom: 1px solid #c2e7b0; display: flex; align-items: center; gap: 8px;">
<el-tag type="success" effect="dark" size="small">按单入库</el-tag>
<span style="font-size: 13px; color: #67C23A;">
已关联采购申请单,入库后将自动标记为「已完成」
</span>
</div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12">
@ -649,6 +670,89 @@
/>
</el-dialog>
<!-- [新增] 从采购单导入弹窗 -->
<el-dialog
v-model="purchaseImportVisible"
title="从采购单导入"
width="900px"
destroy-on-close
:close-on-click-modal="false"
>
<div style="margin-bottom: 15px;">
<el-input
v-model="purchaseImportKeyword"
placeholder="搜索采购单号 / 名称 / 规格..."
clearable
style="width: 300px;"
@keyup.enter="fetchPurchaseImportList"
@clear="fetchPurchaseImportList"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" style="margin-left: 10px;" @click="fetchPurchaseImportList">搜索</el-button>
</div>
<el-table
v-loading="purchaseImportLoading"
:data="purchaseImportList"
border
stripe
:row-class-name="getPurchaseRowClassName"
@row-click="onPurchaseImportSelect"
max-height="400px"
style="width: 100%; cursor: pointer;"
>
<el-table-column prop="request_no" label="采购单号" min-width="200" show-overflow-tooltip />
<el-table-column prop="name" label="物品名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="110" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.spec_model || '-' }}
</template>
</el-table-column>
<el-table-column prop="quantity" label="申请数量" width="85" align="right" />
<el-table-column prop="unit_price" label="单价" width="100" align="right">
<template #default="scope">
{{ scope.row.unit_price ? '¥' + Number(scope.row.unit_price).toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column label="物料信息" min-width="160" show-overflow-tooltip>
<template #default="scope">
<template v-if="scope.row.material">
<el-tag size="small" type="info" effect="plain">{{ scope.row.material.company_name }}</el-tag>
<span style="margin-left: 4px; font-size: 12px; color: #909399;">{{ scope.row.material.type || '-' }}</span>
</template>
<span v-else class="text-placeholder">未关联物料</span>
</template>
</el-table-column>
<el-table-column prop="approved_at" label="审批时间" min-width="155" />
</el-table>
<div style="margin-top: 12px; display: flex; justify-content: flex-end;">
<el-pagination
v-model:current-page="purchaseImportPage"
:page-size="20"
:total="purchaseImportTotal"
layout="total, prev, pager, next"
background
small
@current-change="fetchPurchaseImportList"
/>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="purchaseImportVisible = false">取消</el-button>
<el-button
type="primary"
:disabled="!purchaseImportSelected"
@click="confirmPurchaseImport"
>
确认导入
</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;">
<div v-loading="printLoading" class="preview-box">
@ -678,7 +782,7 @@
<script setup lang="ts">
import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture, EditPen} from '@element-plus/icons-vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture, EditPen, Download} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
const router = useRouter()
import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
@ -698,6 +802,7 @@ import {
getLocationSuggestions,
getFilterOptions
} from '@/api/inbound/buy'
import { getApprovedUnstockedRequests } from '@/api/purchase'
import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
@ -864,6 +969,15 @@ const inspection_report_url = ref('')
// 智能扫码弹窗
const scannerDialogVisible = ref(false)
// [新增] 从采购单导入弹窗
const purchaseImportVisible = ref(false)
const purchaseImportLoading = ref(false)
const purchaseImportList = ref<any[]>([])
const purchaseImportTotal = ref(0)
const purchaseImportPage = ref(1)
const purchaseImportKeyword = ref('')
const purchaseImportSelected = ref<any>(null)
// 库位级联选择器数据
const warehouseOptions = ref<any[]>([])
@ -1062,7 +1176,8 @@ const form = reactive({
currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '',
arrival_photo: [] as string[], inspection_report: [] as string[],
print_copies: 1
print_copies: 1,
request_id: undefined as number | undefined // [新增] 关联采购申请单
})
// ------------------------------------
@ -1164,16 +1279,7 @@ const onMaterialSelected = async (item: any) => {
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
updateInspectionRules()
checkHistoryAndSetMode(item.id)
try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
}
fetchLastLocation(item.id)
}
// 动态更新质检相关校验规则
@ -1449,7 +1555,8 @@ const handleUpdate = (row: any) => {
currency: row.currency, exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email,
source_link: row.source_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || []
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || [],
request_id: row.request_id || undefined
})
// 计算含税单价
if (form.unit_price !== undefined && form.unit_price !== null) {
@ -1663,6 +1770,147 @@ const handleScannerConfirm = (result: string) => {
ElMessage.success('序列号已提取')
}
// ==========================================
// [新增] 从采购单导入逻辑
// ==========================================
// 打开采购单导入弹窗
const openPurchaseImport = () => {
purchaseImportSelected.value = null
purchaseImportKeyword.value = ''
purchaseImportPage.value = 1
purchaseImportVisible.value = true
fetchPurchaseImportList()
}
// 拉取已审批未入库的采购单列表
const fetchPurchaseImportList = async () => {
purchaseImportLoading.value = true
try {
const res: any = await getApprovedUnstockedRequests({
page: purchaseImportPage.value,
limit: 20,
keyword: purchaseImportKeyword.value || undefined
})
if (res.code === 200) {
purchaseImportList.value = res.data.items || []
purchaseImportTotal.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取采购单列表失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.msg || '获取采购单列表失败')
} finally {
purchaseImportLoading.value = false
}
}
// 表格选中行变化(点击已选中行可取消)
const onPurchaseImportSelect = (row: any) => {
if (purchaseImportSelected.value?.id === row.id) {
purchaseImportSelected.value = null // 再次点击取消选中
} else {
purchaseImportSelected.value = row
}
}
// 选中行高亮样式 class
const getPurchaseRowClassName = ({ row }: { row: any }) => {
return purchaseImportSelected.value?.id === row.id ? 'purchase-row-selected' : ''
}
// 确认从采购单导入
const confirmPurchaseImport = () => {
const po = purchaseImportSelected.value
if (!po) {
ElMessage.warning('请先选择一条采购单')
return
}
// 1. 从采购单的 material 信息填充基础物料(触发级联填充)
if (po.material) {
const mat = po.material
form.base_id = mat.id
form.company_name = mat.company_name
form.material_name = mat.name
form.spec_model = mat.spec_model
form.category = mat.category
form.unit = mat.unit
form.material_type = mat.type
materialNameInput.value = mat.name
isCurrentMaterialInspectionRequired.value = mat.is_inspection_required || false
updateInspectionRules()
// 异步加载历史库位和记录模式
checkHistoryAndSetMode(mat.id)
fetchLastLocation(mat.id)
materialOptions.value = [{
id: mat.id, name: mat.name, spec: mat.spec_model,
category: mat.category, company_name: mat.company_name,
type: mat.type, unit: mat.unit,
isInspectionRequired: mat.is_inspection_required
}]
} else if (po.base_id) {
// 如果 material 为空但 base_id 有值,尝试通过 base_id 查询
// 这里简单处理:直接设置 base_id 让用户手动搜索
form.base_id = po.base_id
ElMessage.info('该采购单未附带物料详情,已填充 base_id请手动搜索物料确认')
} else {
ElMessage.warning('该采购单未关联基础物料,无法自动填充物料信息')
return
}
// 2. 填充采购商务信息(单价/总价互相推算,缺哪个补哪个)
const qty = Number(po.quantity) || 1
form.in_quantity = qty
const hasUnitPrice = po.unit_price !== null && po.unit_price !== undefined && Number(po.unit_price) > 0
const hasTotalPrice = po.total_price !== null && po.total_price !== undefined && Number(po.total_price) > 0
if (hasUnitPrice && hasTotalPrice) {
// 两者都有,直接使用
form.unit_price = Number(po.unit_price)
form.total_price = Number(po.total_price)
} else if (hasUnitPrice) {
// 只有单价 → 用数量推算总价
form.unit_price = Number(po.unit_price)
form.total_price = Number((qty * form.unit_price).toFixed(2))
} else if (hasTotalPrice) {
// 只有总价 → 用数量反推单价
form.total_price = Number(po.total_price)
form.unit_price = Number((form.total_price / qty).toFixed(4))
}
// 都没有就都不填
if (po.supplier_link) {
form.source_link = po.supplier_link
}
// 3. 关联采购单号
form.request_id = po.id
// 4. 触发价格联动
updatePrices('pre')
// 5. 显示关联提示
ElMessage.success(`已从采购单【${po.request_no}】导入物料及商务信息,请核对后补充入库详情`)
purchaseImportVisible.value = false
}
// 异步获取历史库位(从 onMaterialSelected 中抽离)
const fetchLastLocation = async (baseId: number) => {
try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: baseId } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
}
}
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
@ -1771,7 +2019,8 @@ const resetForm = () => {
unit_price: undefined, post_tax_unit_price: undefined, total_price: undefined,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [],
print_copies: 1
print_copies: 1,
request_id: undefined
})
}
const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' }
@ -1974,6 +2223,39 @@ onMounted(() => {
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
/* ==========================================
[新增] 采购单导入弹窗 - 选中行高亮样式
========================================== */
/* 注意: row-class-name 直接加在 <tr> 上,需要用 :deep 穿透 scoped */
:deep(.purchase-row-selected) {
background-color: #ecf5ff !important;
}
:deep(.purchase-row-selected) td {
background-color: #ecf5ff !important;
border-bottom-color: #b3d8ff !important;
}
/* 左侧蓝色高亮指示条 */
:deep(.purchase-row-selected) td:first-child {
position: relative;
}
:deep(.purchase-row-selected) td:first-child::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #409EFF;
}
/* stripe 条纹表选中时保持蓝色,不被交替色覆盖 */
:deep(.el-table--striped .el-table__body tr.purchase-row-selected td) {
background-color: #ecf5ff !important;
}
/* hover 时只对普通行做浅灰提示,选中行不变 */
:deep(.el-table__body tr:not(.purchase-row-selected):hover) td {
background-color: #f5f7fa !important;
}
</style>
<style>