李红攀:V2.6.976,添加采购询比、采购统计

This commit is contained in:
2026-07-11 17:55:50 +08:00
parent 69083ba8db
commit f3db15ab74
8 changed files with 1873 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
export interface PurchaseInquiryQuote {
id?: number
inquiryId?: number
invitationId?: number
supplierId?: number
supplierName?: string
contactName?: string
contactPhone?: string
quoteDate?: string | number | Date
unitPrice?: number
totalPrice?: number
paymentTerms?: string
deliveryCycle?: number
expectedArrivalDate?: string | number | Date
qualificationFile?: string
remark?: string
isSelected?: boolean
quoteStatus?: number
submitSource?: string
priceScore?: number
qualityScore?: number
deliveryScore?: number
serviceScore?: number
totalScore?: number
}
export interface PurchaseInquiry {
id?: number
no?: string
productId?: number
productName?: string
requireCount?: number
budgetPrice?: number
inquiryTime?: string | number | Date
deadline?: string | number | Date
status?: number
remark?: string
selectedQuoteId?: number
creatorName?: string
creator?: number
quoteCount?: number
minQuotePrice?: number
quotes?: PurchaseInquiryQuote[]
}
export interface PurchaseInquiryPublishReq {
inquiryId: number
supplierIds: number[]
linkExpireTime?: string | number | Date
}
export interface PurchaseInquiryInvitation {
id?: number
inquiryId?: number
supplierId?: number
supplierName?: string
status?: number
accessToken?: string
portalUrl?: string
linkExpireTime?: string | number | Date
firstViewTime?: string | number | Date
lastViewTime?: string | number | Date
submitTime?: string | number | Date
sendTime?: string | number | Date
quoteId?: number
quoteDate?: string | number | Date
unitPrice?: number
totalPrice?: number
paymentTerms?: string
deliveryCycle?: number
expectedArrivalDate?: string | number | Date
qualificationFile?: string
remark?: string
submitSource?: string
contactName?: string
contactPhone?: string
}
export interface PurchaseInquiryCompareReq {
inquiryId: number
priceWeight: number
qualityWeight: number
deliveryWeight: number
serviceWeight: number
quotes?: {
quoteId: number
qualityScore?: number
serviceScore?: number
}[]
}
export interface PurchaseInquiryCompareQuote {
quoteId: number
supplierId: number
supplierName?: string
unitPrice?: number
totalPrice?: number
deliveryCycle?: number
expectedArrivalDate?: string | number | Date
qualificationFile?: string
priceScore?: number
qualityScore?: number
deliveryScore?: number
serviceScore?: number
totalScore?: number
historicalAvgTotalScore?: number
historicalEvaluationCount?: number
attachmentProvided?: boolean
recommended?: boolean
recommendationReasons?: string[]
riskTips?: string[]
}
export interface PurchaseInquiryCompareResp {
inquiryId: number
inquiryNo?: string
productName?: string
requireCount?: number
budgetPrice?: number
recommendedQuoteId?: number
recommendedSupplierId?: number
recommendedSupplierName?: string
recommendedTotalScore?: number
analysisSummary?: string
analysisTime?: string | number | Date
riskTips?: string[]
quotes: PurchaseInquiryCompareQuote[]
}
export function getPurchaseInquiryPage(params: PageParam) {
return http.get<PageResult<PurchaseInquiry>>('/erp/purchase-inquiry/page', params)
}
export function getPurchaseInquiry(id: number) {
return http.get<PurchaseInquiry>(`/erp/purchase-inquiry/get?id=${id}`)
}
export function createPurchaseInquiry(data: PurchaseInquiry) {
return http.post<number>('/erp/purchase-inquiry/create', data)
}
export function updatePurchaseInquiry(data: PurchaseInquiry) {
return http.put<boolean>('/erp/purchase-inquiry/update', data)
}
export function updatePurchaseInquiryStatus(id: number, status: number) {
return http.put<boolean>(`/erp/purchase-inquiry/update-status?id=${id}&status=${status}`)
}
export function publishPurchaseInquiry(data: PurchaseInquiryPublishReq) {
return http.post<boolean>('/erp/purchase-inquiry/publish', data)
}
export function comparePurchaseInquiry(data: PurchaseInquiryCompareReq) {
return http.post<PurchaseInquiryCompareResp>('/erp/purchase-inquiry/compare-recommend', data)
}
export function getPurchaseInquiryInvitationList(inquiryId: number) {
return http.get<PurchaseInquiryInvitation[]>(`/erp/purchase-inquiry/invitation-list?inquiryId=${inquiryId}`)
}
export function selectPurchaseInquiryQuote(inquiryId: number, quoteId: number) {
return http.put<boolean>(`/erp/purchase-inquiry/select-quote?inquiryId=${inquiryId}&quoteId=${quoteId}`)
}
export function deletePurchaseInquiry(ids: number[]) {
return http.delete<boolean>(`/erp/purchase-inquiry/delete?ids=${ids.join(',')}`)
}

View File

@@ -0,0 +1,75 @@
import { http } from '@/http/http'
export interface PurchaseSummary {
todayPrice: number
yesterdayPrice: number
monthPrice: number
yearPrice: number
}
export interface PurchaseTimeSummary {
time: string
price: number
}
export interface PurchaseTrend {
time: string
price: number
count: number
}
export interface PurchaseProductRank {
productId: number
productName: string
count: number
price: number
inCount: number
returnCount: number
inPrice: number
returnPrice: number
orderCount: number
avgPrice: number
shareRate: number
}
export interface PurchaseSupplierRank {
supplierId: number
supplierName: string
count: number
price: number
inCount: number
returnCount: number
inPrice: number
returnPrice: number
orderCount: number
avgPrice: number
shareRate: number
}
export function getPurchaseSummary() {
return http.get<PurchaseSummary>('/erp/purchase-statistics/summary')
}
export function getPurchaseTimeSummary() {
return http.get<PurchaseTimeSummary[]>('/erp/purchase-statistics/time-summary')
}
export function getPurchaseTrendByWeek(count: number = 12) {
return http.get<PurchaseTrend[]>(`/erp/purchase-statistics/trend-by-week?count=${count}`)
}
export function getPurchaseTrendByMonth(count: number = 12) {
return http.get<PurchaseTrend[]>(`/erp/purchase-statistics/trend-by-month?count=${count}`)
}
export function getPurchaseProductRank(beginTime: string, endTime: string, limit: number = 10, sortBy: 'price' | 'count' = 'price') {
return http.get<PurchaseProductRank[]>('/erp/purchase-statistics/product-rank', { beginTime, endTime, limit, sortBy })
}
export function getPurchaseSupplierRank(beginTime: string, endTime: string, limit: number = 10, sortBy: 'price' | 'count' = 'price') {
return http.get<PurchaseSupplierRank[]>('/erp/purchase-statistics/supplier-rank', { beginTime, endTime, limit, sortBy })
}
export function analyzePurchaseStatistics(data: Record<string, any>) {
return http.post<any>('/erp/purchase-statistics/ai-analysis', data)
}

View File

@@ -0,0 +1,239 @@
<template>
<view class="yd-page-container erp-page">
<wd-navbar title="自动比价" left-arrow placeholder safe-area-inset-top fixed @click-left="handleBack" />
<view class="erp-page__body">
<view class="erp-section">
<view class="erp-section__title">比价单信息</view>
<view class="erp-field-row"><text>询比单号</text><text>{{ inquiry?.no || '-' }}</text></view>
<view class="erp-field-row"><text>产品名称</text><text>{{ inquiry?.productName || '-' }}</text></view>
<view class="erp-field-row"><text>需求数量</text><text>{{ formatCount(inquiry?.requireCount) }}</text></view>
</view>
<view class="erp-section">
<view class="erp-section__title">评分权重合计 {{ totalWeight }}%</view>
<view class="erp-form-item-list">
<view class="erp-form-input-row">
<text class="erp-form-input-label">价格权重</text>
<wd-input v-model="weightConfig.priceWeight" type="number" placeholder="0-100" />
</view>
<view class="erp-form-input-row">
<text class="erp-form-input-label">质量权重</text>
<wd-input v-model="weightConfig.qualityWeight" type="number" placeholder="0-100" />
</view>
<view class="erp-form-input-row">
<text class="erp-form-input-label">交付权重</text>
<wd-input v-model="weightConfig.deliveryWeight" type="number" placeholder="0-100" />
</view>
<view class="erp-form-input-row">
<text class="erp-form-input-label">服务权重</text>
<wd-input v-model="weightConfig.serviceWeight" type="number" placeholder="0-100" />
</view>
</view>
</view>
<view class="erp-section">
<view class="erp-section__title">供应商报价评分</view>
<view v-if="quoteList.length">
<view
v-for="(item, index) in quoteList"
:key="item.id || index"
class="erp-form-item-card erp-form-item-card--padded"
:class="{ 'border-[2rpx] border-[#52c41a]': bestQuote?.id === item.id }"
>
<view class="mb-12rpx flex items-center justify-between">
<text class="erp-card-title">{{ item.supplierName || '未命名供应商' }}</text>
<text class="text-24rpx text-[#1890ff]">{{ item.totalScore != null ? `${item.totalScore.toFixed(2)}` : '未计算' }}</text>
</view>
<view class="erp-field-row"><text>单价</text><text>¥{{ formatPrice(item.unitPrice) }}</text></view>
<view class="erp-field-row"><text>总价</text><text>¥{{ formatPrice(item.totalPrice) }}</text></view>
<view class="erp-field-row"><text>交货周期</text><text>{{ item.deliveryCycle ? `${item.deliveryCycle}` : '-' }}</text></view>
<view class="erp-form-input-row">
<text class="erp-form-input-label">质量评分</text>
<wd-input v-model="item.qualityScore" type="number" placeholder="0-100" />
</view>
<view class="erp-form-input-row">
<text class="erp-form-input-label">服务评分</text>
<wd-input v-model="item.serviceScore" type="number" placeholder="0-100" />
</view>
<view class="mt-12rpx text-24rpx text-[#999]">
价格得分{{ item.priceScore != null ? item.priceScore.toFixed(1) : '-' }}交付得分{{ item.deliveryScore != null ? item.deliveryScore.toFixed(1) : '-' }}
</view>
</view>
</view>
<view v-else class="erp-empty">暂无可比价报价</view>
</view>
<view v-if="bestQuote" class="erp-section">
<view class="erp-section__title">推荐结果</view>
<view class="text-28rpx text-[#52c41a] font-semibold">
推荐供应商{{ bestQuote.supplierName }}综合得分 {{ bestQuote.totalScore?.toFixed(2) }}
</view>
</view>
<view v-if="analysisSummary" class="erp-section">
<view class="erp-section__title">智能分析</view>
<view class="text-26rpx leading-[1.7] text-[#666] whitespace-pre-wrap">{{ analysisSummary }}</view>
<view v-if="compareRiskTips.length" class="mt-16rpx text-24rpx text-[#f5222d]">
<view v-for="item in compareRiskTips" :key="item">{{ item }}</view>
</view>
</view>
</view>
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button class="flex-1" type="primary" :loading="loading" @click="handleCompare">开始比价</wd-button>
<wd-button class="flex-1" type="success" :disabled="!bestQuote" :loading="saving" @click="handleSave">保存结果</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { PurchaseInquiry, PurchaseInquiryCompareResp, PurchaseInquiryQuote } from '@/api/erp/purchase-inquiry'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { comparePurchaseInquiry, getPurchaseInquiry, selectPurchaseInquiryQuote, updatePurchaseInquiry } from '@/api/erp/purchase-inquiry'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{ id?: number | any }>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const inquiry = ref<PurchaseInquiry>()
const quoteList = ref<PurchaseInquiryQuote[]>([])
const loading = ref(false)
const saving = ref(false)
const analysisSummary = ref('')
const compareRiskTips = ref<string[]>([])
const bestQuote = ref<PurchaseInquiryQuote | null>(null)
const weightConfig = reactive({
priceWeight: 40,
qualityWeight: 25,
deliveryWeight: 20,
serviceWeight: 15,
})
const totalWeight = computed(() =>
Number(weightConfig.priceWeight || 0)
+ Number(weightConfig.qualityWeight || 0)
+ Number(weightConfig.deliveryWeight || 0)
+ Number(weightConfig.serviceWeight || 0),
)
function formatPrice(value?: number) {
return Number(value || 0).toFixed(2)
}
function formatCount(value?: number) {
return Number(value || 0).toFixed(2)
}
function handleBack() {
navigateBackPlus(`/pages-erp/purchase-inquiry/detail/index?id=${props.id}`)
}
async function loadData() {
if (!props.id) return
inquiry.value = await getPurchaseInquiry(Number(props.id))
quoteList.value = inquiry.value.quotes || []
bestQuote.value = quoteList.value.find(v => v.isSelected) || null
}
function applyCompareResult(result: PurchaseInquiryCompareResp) {
const currentMap = new Map((quoteList.value || []).map(quote => [quote.id, quote]))
quoteList.value = (result.quotes || []).map(quote => {
const current = currentMap.get(quote.quoteId)
return {
...current,
id: quote.quoteId,
supplierId: quote.supplierId,
supplierName: quote.supplierName,
unitPrice: quote.unitPrice,
totalPrice: quote.totalPrice,
deliveryCycle: quote.deliveryCycle,
expectedArrivalDate: quote.expectedArrivalDate,
qualificationFile: quote.qualificationFile,
priceScore: quote.priceScore,
qualityScore: quote.qualityScore,
deliveryScore: quote.deliveryScore,
serviceScore: quote.serviceScore,
totalScore: quote.totalScore,
isSelected: quote.recommended,
}
})
analysisSummary.value = result.analysisSummary || ''
compareRiskTips.value = result.riskTips || []
bestQuote.value = quoteList.value.find(v => v.id === result.recommendedQuoteId) || null
}
async function handleCompare() {
if (!props.id) return
if (totalWeight.value !== 100) {
toast.warning('权重合计必须等于 100%')
return
}
if (quoteList.value.length < 2) {
toast.warning('至少需要 2 条报价才能比价')
return
}
if (quoteList.value.some(v => !v.unitPrice || Number(v.unitPrice) <= 0)) {
toast.warning('请先补全所有报价单价')
return
}
loading.value = true
try {
const result = await comparePurchaseInquiry({
inquiryId: Number(props.id),
priceWeight: Number(weightConfig.priceWeight),
qualityWeight: Number(weightConfig.qualityWeight),
deliveryWeight: Number(weightConfig.deliveryWeight),
serviceWeight: Number(weightConfig.serviceWeight),
quotes: quoteList.value
.filter(v => !!v.id)
.map(v => ({
quoteId: Number(v.id),
qualityScore: v.qualityScore != null ? Number(v.qualityScore) : undefined,
serviceScore: v.serviceScore != null ? Number(v.serviceScore) : undefined,
})),
})
applyCompareResult(result)
if (bestQuote.value) {
toast.success(`比价完成,推荐供应商:${bestQuote.value.supplierName}`)
}
} finally {
loading.value = false
}
}
async function handleSave() {
if (!inquiry.value?.id || !bestQuote.value) return
saving.value = true
try {
await updatePurchaseInquiry({
...inquiry.value,
quotes: quoteList.value,
})
if (bestQuote.value.id) {
await selectPurchaseInquiryQuote(inquiry.value.id, Number(bestQuote.value.id))
}
toast.success('保存成功')
setTimeout(() => handleBack(), 500)
} finally {
saving.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,241 @@
<template>
<view class="yd-page-container erp-page">
<wd-navbar title="采购询比详情" left-arrow placeholder safe-area-inset-top fixed @click-left="handleBack" />
<view v-if="detail">
<wd-cell-group title="基本信息" border>
<wd-cell title="询比单号" :value="detail.no || '-'" />
<wd-cell title="状态">
<view class="rounded-8rpx px-16rpx py-4rpx text-24rpx" :class="getStatusClass(detail.status)">
{{ getStatusText(detail.status) }}
</view>
</wd-cell>
<wd-cell title="产品" :value="detail.productName || '-'" />
<wd-cell title="需求数量" :value="formatCount(detail.requireCount)" />
<wd-cell title="预算金额" :value="`¥${formatPrice(detail.budgetPrice)}`" />
<wd-cell title="询价时间" :value="formatDate(detail.inquiryTime)" />
<wd-cell title="截止时间" :value="formatDate(detail.deadline)" />
<wd-cell title="创建人" :value="detail.creatorName || '-'" />
</wd-cell-group>
<wd-cell-group v-if="detail.remark" title="备注" border>
<wd-cell :value="detail.remark" />
</wd-cell-group>
<view class="mx-24rpx mt-24rpx">
<view class="mb-16rpx text-32rpx text-[#333] font-semibold">供应商报价{{ detail.quotes?.length || 0 }}</view>
<view v-if="detail.quotes?.length">
<view v-for="(item, index) in detail.quotes" :key="item.id || index" class="mb-16rpx overflow-hidden rounded-12rpx bg-white shadow-sm">
<view class="flex items-center justify-between bg-[#f8f8f8] px-24rpx py-16rpx">
<text class="text-28rpx text-[#333] font-semibold">{{ item.supplierName || '未命名供应商' }}</text>
<text v-if="item.isSelected" class="text-24rpx text-[#52c41a]">已中选</text>
</view>
<view class="p-24rpx">
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">联系人</text>
<text>{{ item.contactName || '-' }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">联系电话</text>
<text>{{ item.contactPhone || '-' }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">单价</text>
<text>¥{{ formatPrice(item.unitPrice) }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">总价</text>
<text>¥{{ formatPrice(item.totalPrice) }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">交货周期</text>
<text>{{ item.deliveryCycle ? `${item.deliveryCycle}` : '-' }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">预计到货</text>
<text>{{ formatDate(item.expectedArrivalDate) }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">付款条件</text>
<text class="ml-16rpx line-clamp-1 text-right" style="max-width: 420rpx;">{{ item.paymentTerms || '-' }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">评分</text>
<text>{{ item.totalScore != null ? item.totalScore.toFixed(2) : '-' }}</text>
</view>
<view v-if="item.remark" class="text-24rpx text-[#999]">备注{{ item.remark }}</view>
</view>
</view>
</view>
<view v-else class="py-60rpx text-center text-28rpx text-[#999]">暂无报价信息</view>
</view>
</view>
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button v-if="hasAccessByCodes(['erp:purchase-inquiry:update'])" class="flex-1" type="primary" @click="handlePublish">
邀请
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update']) && detail?.status !== 30"
class="flex-1"
type="warning"
@click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update']) && detail?.status === 20 && (detail?.quoteCount || 0) >= 2"
class="flex-1"
type="success"
@click="handleCompare"
>
比价
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update-status']) && detail?.status === 10"
class="flex-1"
type="success"
@click="handleUpdateStatus(20)"
>
询价
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update-status']) && detail?.status === 20"
class="flex-1"
type="success"
@click="handleUpdateStatus(30)"
>
完成
</wd-button>
<wd-button v-if="hasAccessByCodes(['erp:purchase-inquiry:delete'])" class="flex-1" type="error" :loading="deleting" @click="handleDelete">
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { PurchaseInquiry } from '@/api/erp/purchase-inquiry'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deletePurchaseInquiry, getPurchaseInquiry, updatePurchaseInquiryStatus } from '@/api/erp/purchase-inquiry'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{ id?: number | any }>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const detail = ref<PurchaseInquiry>()
const deleting = ref(false)
function formatPrice(value?: number) {
return Number(value || 0).toFixed(2)
}
function formatCount(value?: number) {
return Number(value || 0).toFixed(2)
}
function formatDate(value?: string | number | Date) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10)
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function getStatusText(status?: number) {
switch (status) {
case 10: return '待询价'
case 20: return '询价中'
case 30: return '已完成'
default: return '未知'
}
}
function getStatusClass(status?: number) {
switch (status) {
case 10: return 'bg-[#eef3ff] text-[#3f7cff]'
case 20: return 'bg-[#fff7e8] text-[#d48806]'
case 30: return 'bg-[#f0f9eb] text-[#52c41a]'
default: return 'bg-[#f5f5f5] text-[#999]'
}
}
function handleBack() {
navigateBackPlus('/pages-erp/purchase-inquiry/index')
}
async function getDetail() {
if (!props.id) return
detail.value = await getPurchaseInquiry(props.id)
}
function handleEdit() {
uni.navigateTo({ url: `/pages-erp/purchase-inquiry/form/index?id=${props.id}` })
}
function handlePublish() {
uni.navigateTo({ url: `/pages-erp/purchase-inquiry/publish/index?id=${props.id}` })
}
function handleCompare() {
uni.navigateTo({ url: `/pages-erp/purchase-inquiry/compare/index?id=${props.id}` })
}
function handleUpdateStatus(status: number) {
if (!props.id) return
const actionText = status === 20 ? '开始询价' : '完成'
uni.showModal({
title: '提示',
content: `确定${actionText}该询比单吗?`,
success: async (res) => {
if (!res.confirm) return
try {
await updatePurchaseInquiryStatus(props.id, status)
toast.success(`${actionText}成功`)
getDetail()
} catch {
// http handled
}
},
})
}
function handleDelete() {
if (!props.id) return
uni.showModal({
title: '提示',
content: '确定要删除该采购询比吗?',
success: async (res) => {
if (!res.confirm) return
deleting.value = true
try {
await deletePurchaseInquiry([props.id])
toast.success('删除成功')
setTimeout(() => handleBack(), 500)
} finally {
deleting.value = false
}
},
})
}
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,266 @@
<template>
<view class="yd-page-container erp-page">
<wd-navbar :title="pageTitle" left-arrow placeholder safe-area-inset-top fixed @click-left="handleBack" />
<view class="erp-form-body">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group title="基本信息" border>
<wd-cell title="询比单号" :value="formData.no || '保存后自动生成'" />
<wd-cell title="询价时间" title-width="180rpx" prop="inquiryTime" center>
<wd-datetime-picker v-model="formData.inquiryTime" type="date" label="" placeholder="请选择询价时间" />
</wd-cell>
<wd-cell title="截止时间" title-width="180rpx" center>
<wd-datetime-picker v-model="formData.deadline" type="date" label="" placeholder="请选择截止时间" />
</wd-cell>
<wd-cell title="产品" title-width="180rpx" prop="productId" center>
<wd-picker v-model="formData.productId" :columns="productColumns" placeholder="请选择产品" @confirm="onProductConfirm" />
</wd-cell>
<wd-input v-model="formData.requireCount" label="需求数量" label-width="180rpx" type="number" placeholder="请输入需求数量" clearable />
<wd-input v-model="formData.budgetPrice" label="预算金额" label-width="180rpx" type="number" placeholder="请输入预算金额" clearable />
<wd-textarea v-model="formData.remark" label="备注" label-width="180rpx" placeholder="请输入备注" :maxlength="200" show-word-limit clearable />
</wd-cell-group>
</wd-form>
<view class="erp-form-section-heading">
<view class="text-32rpx text-[#333] font-semibold">供应商报价{{ formData.quotes.length }}</view>
<wd-button size="small" type="primary" @click="handleAddQuote">+ 添加报价</wd-button>
</view>
<view v-for="(item, index) in formData.quotes" :key="item.id || index" class="erp-form-item-card">
<view class="erp-form-item-card__header">
<text class="erp-card-title">报价 #{{ index + 1 }}</text>
<wd-button size="small" type="error" plain @click="handleRemoveQuote(index)">删除</wd-button>
</view>
<view class="erp-form-item-list">
<view class="mb-16rpx">
<text class="mb-8rpx block text-24rpx text-[#999]">供应商</text>
<wd-picker v-model="item.supplierId" :columns="supplierColumns" placeholder="请选择供应商" @confirm="(e: any) => onSupplierConfirm(e, index)" />
</view>
<view class="mb-16rpx flex gap-16rpx">
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">联系人</text>
<wd-input v-model="item.contactName" placeholder="请输入联系人" clearable />
</view>
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">联系电话</text>
<wd-input v-model="item.contactPhone" placeholder="请输入联系电话" clearable />
</view>
</view>
<view class="mb-16rpx flex gap-16rpx">
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">单价</text>
<wd-input v-model="item.unitPrice" type="number" placeholder="请输入单价" clearable />
</view>
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">总价</text>
<text class="block pt-16rpx text-28rpx text-[#333]">¥{{ calcTotalPrice(item) }}</text>
</view>
</view>
<view class="mb-16rpx flex gap-16rpx">
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">交货周期()</text>
<wd-input v-model="item.deliveryCycle" type="number" placeholder="请输入交货周期" clearable />
</view>
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">预计到货</text>
<wd-datetime-picker v-model="item.expectedArrivalDate" type="date" label="" placeholder="请选择日期" />
</view>
</view>
<view class="mb-16rpx">
<text class="mb-8rpx block text-24rpx text-[#999]">付款条件</text>
<wd-input v-model="item.paymentTerms" placeholder="请输入付款条件" clearable />
</view>
<view class="mb-16rpx">
<text class="mb-8rpx block text-24rpx text-[#999]">资质附件 URL</text>
<wd-input v-model="item.qualificationFile" placeholder="请输入附件地址" clearable />
</view>
<view>
<text class="mb-8rpx block text-24rpx text-[#999]">备注</text>
<wd-input v-model="item.remark" placeholder="请输入备注" clearable />
</view>
</view>
</view>
<view v-if="formData.quotes.length === 0" class="py-60rpx text-center text-28rpx text-[#999]">
暂无报价点击添加报价开始录入
</view>
</view>
<view class="yd-detail-footer">
<wd-button type="primary" block :loading="submitting" @click="handleSubmit">保存</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { ProductSimple } from '@/api/erp/product'
import type { PurchaseInquiry, PurchaseInquiryQuote } from '@/api/erp/purchase-inquiry'
import type { SupplierSimple } from '@/api/erp/supplier'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createPurchaseInquiry, getPurchaseInquiry, updatePurchaseInquiry } from '@/api/erp/purchase-inquiry'
import { getProductSimpleList } from '@/api/erp/product'
import { getSupplierSimpleList } from '@/api/erp/supplier'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{ id?: number | any }>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const productList = ref<ProductSimple[]>([])
const supplierList = ref<SupplierSimple[]>([])
interface InquiryFormModel extends Omit<PurchaseInquiry, 'inquiryTime' | 'deadline' | 'quotes'> {
inquiryTime?: string | number
deadline?: string | number
quotes: (Omit<PurchaseInquiryQuote, 'quoteDate' | 'expectedArrivalDate'> & {
quoteDate?: string | number
expectedArrivalDate?: string | number
})[]
}
const formData = ref<InquiryFormModel>({
id: undefined,
no: undefined,
productId: undefined,
productName: undefined,
requireCount: 1,
budgetPrice: 0,
inquiryTime: Date.now(),
deadline: undefined,
remark: '',
quotes: [],
})
const formRules = {
productId: [{ required: true, message: '产品不能为空' }],
inquiryTime: [{ required: true, message: '询价时间不能为空' }],
requireCount: [{ required: true, message: '需求数量不能为空' }],
}
const pageTitle = computed(() => props.id ? '编辑采购询比' : '新增采购询比')
const productColumns = computed(() => [productList.value.map(v => ({ label: v.name, value: v.id }))])
const supplierColumns = computed(() => [supplierList.value.map(v => ({ label: v.name, value: v.id }))])
function onProductConfirm({ value }: any) {
const productId = value?.[0] ? Number(value[0]) : undefined
formData.value.productId = productId
const product = productList.value.find(v => v.id === productId)
formData.value.productName = product?.name
}
function onSupplierConfirm({ value }: any, index: number) {
const supplierId = value?.[0] ? Number(value[0]) : undefined
const current = formData.value.quotes?.[index]
if (!current) return
current.supplierId = supplierId
const supplier = supplierList.value.find(v => v.id === supplierId)
current.supplierName = supplier?.name
}
function calcTotalPrice(item: PurchaseInquiryQuote) {
const unitPrice = Number(item.unitPrice || 0)
const requireCount = Number(formData.value.requireCount || 0)
return (unitPrice * requireCount).toFixed(2)
}
function handleAddQuote() {
formData.value.quotes = formData.value.quotes || []
formData.value.quotes.push({
supplierId: undefined,
supplierName: undefined,
contactName: '',
contactPhone: '',
unitPrice: undefined,
totalPrice: undefined,
paymentTerms: '',
deliveryCycle: undefined,
expectedArrivalDate: undefined,
qualificationFile: '',
remark: '',
})
}
function handleRemoveQuote(index: number) {
uni.showModal({
title: '提示',
content: `确定删除第 ${index + 1} 条报价吗?`,
success: (res) => {
if (res.confirm) {
formData.value.quotes?.splice(index, 1)
}
},
})
}
function handleBack() {
navigateBackPlus('/pages-erp/purchase-inquiry/index')
}
async function getDetail() {
if (!props.id) return
const detail = await getPurchaseInquiry(props.id)
formData.value = {
...detail,
inquiryTime: detail.inquiryTime as string | number | undefined,
deadline: detail.deadline as string | number | undefined,
quotes: (detail.quotes || []).map(item => ({
...item,
quoteDate: item.quoteDate as string | number | undefined,
expectedArrivalDate: item.expectedArrivalDate as string | number | undefined,
})),
}
formData.value.quotes = formData.value.quotes || []
}
async function handleSubmit() {
const { valid } = await formRef.value!.validate()
if (!valid) return
const quotes = formData.value.quotes || []
for (let i = 0; i < quotes.length; i++) {
const item = quotes[i]
if (!item.supplierId) {
toast.warning(`${i + 1} 条报价未选择供应商`)
return
}
if (!item.unitPrice || Number(item.unitPrice) <= 0) {
toast.warning(`${i + 1} 条报价单价不能为空`)
return
}
item.totalPrice = Number(calcTotalPrice(item))
}
submitting.value = true
try {
if (props.id) {
await updatePurchaseInquiry(formData.value as PurchaseInquiry)
toast.success('修改成功')
} else {
await createPurchaseInquiry(formData.value as PurchaseInquiry)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
submitting.value = false
}
}
onMounted(async () => {
const [products, suppliers] = await Promise.all([getProductSimpleList(), getSupplierSimpleList()])
productList.value = products || []
supplierList.value = suppliers || []
getDetail()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,392 @@
<template>
<view class="yd-page-container erp-page">
<wd-navbar
title="采购询比"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="handleBack"
/>
<view @click="searchVisible = true">
<wd-search :placeholder="searchPlaceholder" hide-cancel disabled />
</view>
<view class="px-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-20rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleCardClick(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-30rpx text-[#333] font-semibold">
{{ item.no || '-' }}
</view>
<view class="rounded-8rpx px-16rpx py-4rpx text-24rpx" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</view>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">产品</text>
<text class="ml-16rpx line-clamp-1 text-right" style="max-width: 420rpx;">{{ item.productName || '-' }}</text>
</view>
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">询价时间</text>
<text>{{ formatDate(item.inquiryTime) }}</text>
</view>
<view class="mb-12rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">截止时间</text>
<text>{{ formatDate(item.deadline) }}</text>
</view>
<view class="flex items-center justify-around mt-12rpx pt-16rpx border-t border-[#f0f0f0]">
<view class="text-center">
<view class="text-30rpx text-[#333] font-semibold">{{ formatCount(item.requireCount) }}</view>
<view class="mt-4rpx text-22rpx text-[#999]">需求数量</view>
</view>
<view class="text-center">
<view class="text-30rpx text-[#e6a23c] font-semibold">¥{{ formatPrice(item.budgetPrice) }}</view>
<view class="mt-4rpx text-22rpx text-[#999]">预算金额</view>
</view>
<view class="text-center">
<view class="text-30rpx text-[#409eff] font-semibold">{{ item.quoteCount || 0 }}</view>
<view class="mt-4rpx text-22rpx text-[#999]">报价数</view>
</view>
<view class="text-center">
<view class="text-30rpx text-[#52c41a] font-semibold">¥{{ formatPrice(item.minQuotePrice) }}</view>
<view class="mt-4rpx text-22rpx text-[#999]">最低报价</view>
</view>
</view>
</view>
<view class="flex flex-wrap gap-12rpx px-24rpx pb-20rpx" @click.stop>
<wd-button v-if="hasAccessByCodes(['erp:purchase-inquiry:query'])" size="small" plain @click="handleDetail(item)">
详情
</wd-button>
<wd-button v-if="hasAccessByCodes(['erp:purchase-inquiry:update'])" size="small" type="primary" plain @click="handlePublish(item)">
邀请
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update']) && item.status !== 30"
size="small"
type="primary"
plain
@click="handleEdit(item)"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update-status']) && item.status === 10"
size="small"
type="success"
plain
@click="handleUpdateStatus(item.id!, 20)"
>
询价
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update']) && item.status === 20 && (item.quoteCount || 0) >= 2"
size="small"
type="warning"
plain
@click="handleCompare(item)"
>
比价
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:update-status']) && item.status === 20"
size="small"
type="success"
plain
@click="handleUpdateStatus(item.id!, 30)"
>
完成
</wd-button>
<wd-button
v-if="hasAccessByCodes(['erp:purchase-inquiry:delete'])"
size="small"
type="error"
plain
@click="handleDelete(item.id!)"
>
删除
</wd-button>
</view>
</view>
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无采购询比数据" />
</view>
<wd-loadmore v-if="list.length > 0" :state="loadMoreState" @reload="loadMore" />
</view>
<wd-fab
v-if="hasAccessByCodes(['erp:purchase-inquiry:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
<wd-popup v-model="searchVisible" position="top" @close="searchVisible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">询比单号</view>
<wd-input v-model="searchForm.no" placeholder="请输入询比单号" clearable />
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">产品</view>
<wd-picker v-model="searchForm.productId" :columns="productColumns" placeholder="请选择产品" @confirm="onProductConfirm" />
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">状态</view>
<wd-radio-group v-model="searchForm.status" shape="button">
<wd-radio :value="-1">全部</wd-radio>
<wd-radio :value="10">待询价</wd-radio>
<wd-radio :value="20">询价中</wd-radio>
<wd-radio :value="30">已完成</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">创建人</view>
<wd-picker v-model="searchForm.creator" :columns="userColumns" placeholder="请选择创建人" @confirm="onUserConfirm" />
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">重置</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">搜索</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
<script lang="ts" setup>
import type { PurchaseInquiry } from '@/api/erp/purchase-inquiry'
import type { ProductSimple } from '@/api/erp/product'
import type { User } from '@/api/system/user'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deletePurchaseInquiry, getPurchaseInquiryPage, updatePurchaseInquiryStatus } from '@/api/erp/purchase-inquiry'
import { getProductSimpleList } from '@/api/erp/product'
import { getSimpleUserList } from '@/api/system/user'
import { useAccess } from '@/hooks/useAccess'
import { getNavbarHeight, navigateBackPlus } from '@/utils'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const total = ref(0)
const list = ref<PurchaseInquiry[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref<{ pageNo: number; pageSize: number } & Record<string, any>>({
pageNo: 1,
pageSize: 10,
})
const searchVisible = ref(false)
const productList = ref<ProductSimple[]>([])
const userList = ref<User[]>([])
const searchForm = reactive({
no: undefined as string | undefined,
productId: undefined as number | undefined,
status: -1,
creator: undefined as number | undefined,
})
const productColumns = computed(() => [[{ label: '全部', value: '' }, ...productList.value.map(v => ({ label: v.name, value: v.id }))]])
const userColumns = computed(() => [[{ label: '全部', value: '' }, ...userList.value.map(v => ({ label: v.nickname, value: v.id }))]])
const searchPlaceholder = computed(() => {
const conditions: string[] = []
if (searchForm.no) conditions.push(`单号:${searchForm.no}`)
if (searchForm.productId) {
const product = productList.value.find(v => v.id === searchForm.productId)
conditions.push(`产品:${product?.name || searchForm.productId}`)
}
if (searchForm.status !== -1) conditions.push(`状态:${getStatusText(searchForm.status)}`)
if (searchForm.creator) {
const user = userList.value.find(v => v.id === searchForm.creator)
conditions.push(`创建人:${user?.nickname || searchForm.creator}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索采购询比'
})
function formatDate(value?: string | number | Date) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10)
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function formatPrice(value?: number) {
return Number(value || 0).toFixed(2)
}
function formatCount(value?: number) {
return Number(value || 0).toFixed(2)
}
function getStatusText(status?: number) {
switch (status) {
case 10: return '待询价'
case 20: return '询价中'
case 30: return '已完成'
default: return '未知'
}
}
function getStatusClass(status?: number) {
switch (status) {
case 10: return 'bg-[#eef3ff] text-[#3f7cff]'
case 20: return 'bg-[#fff7e8] text-[#d48806]'
case 30: return 'bg-[#f0f9eb] text-[#52c41a]'
default: return 'bg-[#f5f5f5] text-[#999]'
}
}
function onProductConfirm({ value }: any) {
searchForm.productId = value?.[0] ? Number(value[0]) : undefined
}
function onUserConfirm({ value }: any) {
searchForm.creator = value?.[0] ? Number(value[0]) : undefined
}
function handleBack() {
navigateBackPlus()
}
async function getList() {
loadMoreState.value = 'loading'
try {
const data = await getPurchaseInquiryPage({ ...queryParams.value })
list.value = [...list.value, ...(data.list || [])]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
function handleSearch() {
searchVisible.value = false
queryParams.value = {
pageNo: 1,
pageSize: queryParams.value.pageSize,
no: searchForm.no,
productId: searchForm.productId,
status: searchForm.status === -1 ? undefined : searchForm.status,
creator: searchForm.creator,
}
list.value = []
getList()
}
function handleReset() {
searchForm.no = undefined
searchForm.productId = undefined
searchForm.status = -1
searchForm.creator = undefined
searchVisible.value = false
queryParams.value = { pageNo: 1, pageSize: 10 }
list.value = []
getList()
}
function loadMore() {
if (loadMoreState.value === 'finished') return
queryParams.value.pageNo++
getList()
}
function handleCardClick(item: PurchaseInquiry) {
if (item.status === 30) {
handleDetail(item)
} else {
handleEdit(item)
}
}
function handleAdd() {
uni.navigateTo({ url: '/pages-erp/purchase-inquiry/form/index' })
}
function handleDetail(item: PurchaseInquiry) {
uni.navigateTo({ url: `/pages-erp/purchase-inquiry/detail/index?id=${item.id}` })
}
function handleEdit(item: PurchaseInquiry) {
uni.navigateTo({ url: `/pages-erp/purchase-inquiry/form/index?id=${item.id}` })
}
function handlePublish(item: PurchaseInquiry) {
uni.navigateTo({ url: `/pages-erp/purchase-inquiry/publish/index?id=${item.id}` })
}
function handleCompare(item: PurchaseInquiry) {
uni.navigateTo({ url: `/pages-erp/purchase-inquiry/compare/index?id=${item.id}` })
}
function handleUpdateStatus(id: number, status: number) {
const actionText = status === 20 ? '开始询价' : '完成'
uni.showModal({
title: '提示',
content: `确定${actionText}该询比单吗?`,
success: async (res) => {
if (!res.confirm) return
try {
await updatePurchaseInquiryStatus(id, status)
toast.success(`${actionText}成功`)
list.value = []
queryParams.value.pageNo = 1
getList()
} catch {
// http handled
}
},
})
}
function handleDelete(id: number) {
uni.showModal({
title: '提示',
content: '确定要删除该采购询比吗?',
success: async (res) => {
if (!res.confirm) return
try {
await deletePurchaseInquiry([id])
toast.success('删除成功')
list.value = []
queryParams.value.pageNo = 1
getList()
} catch {
// http handled
}
},
})
}
onReachBottom(() => {
loadMore()
})
onMounted(async () => {
const [products, users] = await Promise.all([getProductSimpleList(), getSimpleUserList()])
productList.value = products || []
userList.value = users || []
getList()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,218 @@
<template>
<view class="yd-page-container erp-page">
<wd-navbar title="询比邀请" left-arrow placeholder safe-area-inset-top fixed @click-left="handleBack" />
<view class="erp-page__body">
<view class="erp-section">
<view class="erp-section__title">询比信息</view>
<view class="erp-field-row"><text>询比单号</text><text>{{ inquiry?.no || '-' }}</text></view>
<view class="erp-field-row"><text>产品</text><text>{{ inquiry?.productName || '-' }}</text></view>
<view class="erp-field-row"><text>状态</text><text>{{ getStatusText(inquiry?.status) }}</text></view>
<view class="erp-field-row"><text>截止时间</text><text>{{ formatDateTime(inquiry?.deadline) }}</text></view>
</view>
<view class="erp-section">
<view class="erp-section__title">邀请设置</view>
<wd-cell-group border>
<wd-cell title="链接失效时间" title-width="180rpx" center>
<wd-datetime-picker v-model="formData.linkExpireTime" type="datetime" label="" placeholder="请选择链接失效时间" />
</wd-cell>
</wd-cell-group>
<view class="mt-24rpx text-26rpx text-[#666]">邀请供应商</view>
<view class="mt-16rpx">
<wd-checkbox-group v-model="formData.supplierIds" cell shape="button">
<wd-checkbox v-for="item in supplierList" :key="item.id" :model-value="item.id">
{{ item.name }}
</wd-checkbox>
</wd-checkbox-group>
</view>
<view class="mt-24rpx">
<wd-button type="primary" block :loading="submitting" @click="handleSubmit">发布 / 重新生成链接</wd-button>
</view>
</view>
<view class="erp-section">
<view class="erp-section__title">邀请概览</view>
<view class="grid grid-cols-2 gap-16rpx">
<view class="erp-metric-card">
<view class="erp-metric-card__label">已邀请</view>
<view class="erp-metric-card__value">{{ invitationList.length }}</view>
</view>
<view class="erp-metric-card">
<view class="erp-metric-card__label">已查看</view>
<view class="erp-metric-card__value">{{ summary.viewedCount }}</view>
</view>
<view class="erp-metric-card">
<view class="erp-metric-card__label">已报价</view>
<view class="erp-metric-card__value">{{ summary.quotedCount }}</view>
</view>
<view class="erp-metric-card">
<view class="erp-metric-card__label">待报价</view>
<view class="erp-metric-card__value">{{ summary.pendingCount }}</view>
</view>
</view>
</view>
<view class="erp-section">
<view class="erp-section__title">邀请记录</view>
<view v-if="invitationList.length">
<view v-for="item in invitationList" :key="item.id" class="erp-form-item-card erp-form-item-card--padded">
<view class="mb-12rpx flex items-center justify-between">
<text class="erp-card-title">{{ item.supplierName || '-' }}</text>
<text class="text-24rpx" :class="getInvitationStatusClass(item.status)">{{ getInvitationStatusText(item.status) }}</text>
</view>
<view class="erp-field-row"><text>报价单价</text><text>{{ item.unitPrice != null ? `¥${Number(item.unitPrice).toFixed(2)}` : '-' }}</text></view>
<view class="erp-field-row"><text>报价总额</text><text>{{ item.totalPrice != null ? `¥${Number(item.totalPrice).toFixed(2)}` : '-' }}</text></view>
<view class="erp-field-row"><text>预计到货</text><text>{{ formatDateTime(item.expectedArrivalDate) }}</text></view>
<view class="erp-field-row"><text>报价时间</text><text>{{ formatDateTime(item.quoteDate) }}</text></view>
<view class="erp-field-row"><text>首次查看</text><text>{{ formatDateTime(item.firstViewTime) }}</text></view>
<view class="erp-field-row"><text>提交来源</text><text>{{ item.submitSource || '-' }}</text></view>
<view class="mt-16rpx flex gap-12rpx">
<wd-button size="small" plain @click="copyLink(item.portalUrl)">复制链接</wd-button>
<wd-button v-if="item.qualificationFile" size="small" type="primary" plain @click="copyLink(item.qualificationFile)">复制附件地址</wd-button>
</view>
</view>
</view>
<view v-else class="erp-empty">暂无邀请记录</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { PurchaseInquiry, PurchaseInquiryInvitation, PurchaseInquiryPublishReq } from '@/api/erp/purchase-inquiry'
import type { SupplierSimple } from '@/api/erp/supplier'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getPurchaseInquiry, getPurchaseInquiryInvitationList, publishPurchaseInquiry } from '@/api/erp/purchase-inquiry'
import { getSupplierSimpleList } from '@/api/erp/supplier'
import { navigateBackPlus } from '@/utils'
const props = defineProps<{ id?: number | any }>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const inquiry = ref<PurchaseInquiry>()
const supplierList = ref<SupplierSimple[]>([])
const invitationList = ref<PurchaseInquiryInvitation[]>([])
const submitting = ref(false)
type PublishFormModel = Omit<PurchaseInquiryPublishReq, 'linkExpireTime'> & {
linkExpireTime?: string | number
}
const formData = reactive<PublishFormModel>({
inquiryId: 0,
supplierIds: [],
linkExpireTime: undefined,
})
const summary = computed(() => {
const viewedCount = invitationList.value.filter(v => [20, 30].includes(Number(v.status))).length
const quotedCount = invitationList.value.filter(v => !!v.quoteId || Number(v.status) === 30).length
const pendingCount = invitationList.value.filter(v => Number(v.status) === 10).length
return { viewedCount, quotedCount, pendingCount }
})
function getStatusText(status?: number) {
switch (status) {
case 10: return '待询价'
case 20: return '询价中'
case 30: return '已完成'
default: return '-'
}
}
function getInvitationStatusText(status?: number) {
switch (status) {
case 10: return '待报价'
case 20: return '已查看'
case 30: return '已报价'
case 40: return '已失效'
case 50: return '已作废'
default: return '-'
}
}
function getInvitationStatusClass(status?: number) {
switch (status) {
case 20: return 'text-[#d48806]'
case 30: return 'text-[#52c41a]'
case 40: return 'text-[#f5222d]'
default: return 'text-[#999]'
}
}
function formatDateTime(value?: string | number | Date) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}`
}
function handleBack() {
navigateBackPlus(`/pages-erp/purchase-inquiry/detail/index?id=${props.id}`)
}
async function loadData() {
if (!props.id) return
formData.inquiryId = Number(props.id)
const [detail, suppliers, invitations] = await Promise.all([
getPurchaseInquiry(Number(props.id)),
getSupplierSimpleList(),
getPurchaseInquiryInvitationList(Number(props.id)),
])
inquiry.value = detail
supplierList.value = suppliers || []
invitationList.value = invitations || []
formData.supplierIds = invitationList.value.map(v => Number(v.supplierId)).filter(Boolean)
formData.linkExpireTime = (invitationList.value[0]?.linkExpireTime || detail?.deadline) as string | number | undefined
}
async function handleSubmit() {
if (!formData.supplierIds.length) {
toast.warning('请至少选择一个供应商')
return
}
submitting.value = true
try {
await publishPurchaseInquiry({
inquiryId: formData.inquiryId,
supplierIds: formData.supplierIds,
linkExpireTime: formData.linkExpireTime,
} as PurchaseInquiryPublishReq)
toast.success('询比邀请已发布')
await loadData()
} finally {
submitting.value = false
}
}
function copyLink(url?: string) {
if (!url) {
toast.warning('暂无可复制链接')
return
}
uni.setClipboardData({
data: url,
success: () => toast.success('链接已复制'),
})
}
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,271 @@
<template>
<view class="yd-page-container erp-page">
<wd-navbar title="采购统计" left-arrow placeholder safe-area-inset-top fixed @click-left="handleBack" />
<view class="erp-page__body">
<view class="grid grid-cols-2 gap-16rpx">
<view class="erp-metric-card">
<view class="erp-metric-card__label">今日采购</view>
<view class="erp-metric-card__value">¥{{ formatPrice(summary?.todayPrice) }}</view>
</view>
<view class="erp-metric-card">
<view class="erp-metric-card__label">昨日采购</view>
<view class="erp-metric-card__value">¥{{ formatPrice(summary?.yesterdayPrice) }}</view>
</view>
<view class="erp-metric-card">
<view class="erp-metric-card__label">本月采购</view>
<view class="erp-metric-card__value">¥{{ formatPrice(summary?.monthPrice) }}</view>
</view>
<view class="erp-metric-card">
<view class="erp-metric-card__label">今年采购</view>
<view class="erp-metric-card__value">¥{{ formatPrice(summary?.yearPrice) }}</view>
</view>
</view>
<view class="erp-section">
<view class="mb-16rpx flex items-center justify-between">
<view class="erp-section__title !mb-0 !p-0 !border-0">采购趋势</view>
<wd-radio-group v-model="trendType" shape="button" @change="handleTrendTypeChange">
<wd-radio value="week">按周</wd-radio>
<wd-radio value="month">按月</wd-radio>
</wd-radio-group>
</view>
<view class="grid grid-cols-2 gap-16rpx mb-16rpx">
<view class="erp-metric-card">
<view class="erp-metric-card__label">净采购金额</view>
<view class="erp-metric-card__value">¥{{ formatPrice(trendAnalysis.totalPrice) }}</view>
</view>
<view class="erp-metric-card">
<view class="erp-metric-card__label">净采购数量</view>
<view class="erp-metric-card__value">{{ formatCount(trendAnalysis.totalCount) }}</view>
</view>
</view>
<view v-if="trendList.length">
<view v-for="item in trendList" :key="item.time" class="erp-form-item-card erp-form-item-card--padded">
<view class="mb-8rpx flex items-center justify-between">
<text class="erp-card-title">{{ item.time }}</text>
<text class="text-24rpx text-[#999]">数量 {{ formatCount(item.count) }}</text>
</view>
<view class="text-32rpx font-semibold text-[#1890ff]">¥{{ formatPrice(item.price) }}</view>
</view>
</view>
<view v-else class="erp-empty">暂无趋势数据</view>
</view>
<view class="erp-section">
<view class="mb-16rpx flex items-center justify-between">
<view class="erp-section__title !mb-0 !p-0 !border-0">产品采购统计</view>
<wd-radio-group v-model="productSortBy" shape="button" @change="getProductRank">
<wd-radio value="price">按金额</wd-radio>
<wd-radio value="count">按数量</wd-radio>
</wd-radio-group>
</view>
<view class="mb-16rpx flex gap-16rpx">
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">开始日期</text>
<wd-datetime-picker v-model="productDateRange[0]" type="date" label="" placeholder="开始日期" />
</view>
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">结束日期</text>
<wd-datetime-picker v-model="productDateRange[1]" type="date" label="" placeholder="结束日期" />
</view>
</view>
<wd-button type="primary" plain block @click="getProductRank">刷新产品榜单</wd-button>
<view v-if="productRankList.length" class="mt-16rpx">
<view v-for="(item, index) in productRankList" :key="item.productId || index" class="erp-form-item-card erp-form-item-card--padded">
<view class="mb-8rpx flex items-center justify-between">
<text class="erp-card-title">#{{ index + 1 }} {{ item.productName }}</text>
<text class="text-24rpx text-[#999]">占比 {{ formatPercent(item.shareRate) }}</text>
</view>
<view class="erp-field-row"><text>净采购金额</text><text>¥{{ formatPrice(item.price) }}</text></view>
<view class="erp-field-row"><text>净采购数量</text><text>{{ formatCount(item.count) }}</text></view>
<view class="erp-field-row"><text>退货金额</text><text>¥{{ formatPrice(item.returnPrice) }}</text></view>
<view class="erp-field-row"><text>退货数量</text><text>{{ formatCount(item.returnCount) }}</text></view>
<view class="erp-field-row"><text>均价</text><text>¥{{ formatPrice(item.avgPrice) }}</text></view>
<view class="erp-field-row"><text>单据数</text><text>{{ item.orderCount || 0 }}</text></view>
</view>
</view>
<view v-else class="erp-empty">暂无产品统计数据</view>
</view>
<view class="erp-section">
<view class="mb-16rpx flex items-center justify-between">
<view class="erp-section__title !mb-0 !p-0 !border-0">供应商采购统计</view>
<wd-radio-group v-model="supplierSortBy" shape="button" @change="getSupplierRank">
<wd-radio value="price">按金额</wd-radio>
<wd-radio value="count">按数量</wd-radio>
</wd-radio-group>
</view>
<view class="mb-16rpx flex gap-16rpx">
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">开始日期</text>
<wd-datetime-picker v-model="supplierDateRange[0]" type="date" label="" placeholder="开始日期" />
</view>
<view class="flex-1">
<text class="mb-8rpx block text-24rpx text-[#999]">结束日期</text>
<wd-datetime-picker v-model="supplierDateRange[1]" type="date" label="" placeholder="结束日期" />
</view>
</view>
<wd-button type="primary" plain block @click="getSupplierRank">刷新供应商榜单</wd-button>
<view v-if="supplierRankList.length" class="mt-16rpx">
<view v-for="(item, index) in supplierRankList" :key="item.supplierId || index" class="erp-form-item-card erp-form-item-card--padded">
<view class="mb-8rpx flex items-center justify-between">
<text class="erp-card-title">#{{ index + 1 }} {{ item.supplierName }}</text>
<text class="text-24rpx text-[#999]">占比 {{ formatPercent(item.shareRate) }}</text>
</view>
<view class="erp-field-row"><text>净采购金额</text><text>¥{{ formatPrice(item.price) }}</text></view>
<view class="erp-field-row"><text>净采购数量</text><text>{{ formatCount(item.count) }}</text></view>
<view class="erp-field-row"><text>退货金额</text><text>¥{{ formatPrice(item.returnPrice) }}</text></view>
<view class="erp-field-row"><text>退货数量</text><text>{{ formatCount(item.returnCount) }}</text></view>
<view class="erp-field-row"><text>均价</text><text>¥{{ formatPrice(item.avgPrice) }}</text></view>
<view class="erp-field-row"><text>单据数</text><text>{{ item.orderCount || 0 }}</text></view>
</view>
</view>
<view v-else class="erp-empty">暂无供应商统计数据</view>
</view>
<view class="erp-section">
<view class="mb-16rpx flex items-center justify-between">
<view class="erp-section__title !mb-0 !p-0 !border-0">智能分析</view>
<wd-button type="primary" size="small" :loading="aiLoading" @click="analyzeWithAI">生成分析</wd-button>
</view>
<view v-if="aiResult" class="text-26rpx leading-[1.7] text-[#666] whitespace-pre-wrap">{{ aiResult }}</view>
<view v-else class="erp-empty">暂无分析内容</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { PurchaseProductRank, PurchaseSummary, PurchaseSupplierRank, PurchaseTrend } from '@/api/erp/purchase-statistics'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import {
analyzePurchaseStatistics,
getPurchaseProductRank,
getPurchaseSummary,
getPurchaseSupplierRank,
getPurchaseTrendByMonth,
getPurchaseTrendByWeek,
} from '@/api/erp/purchase-statistics'
import { navigateBackPlus } from '@/utils'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const summary = ref<PurchaseSummary>()
const trendList = ref<PurchaseTrend[]>([])
const productRankList = ref<PurchaseProductRank[]>([])
const supplierRankList = ref<PurchaseSupplierRank[]>([])
const trendType = ref<'week' | 'month'>('month')
const productSortBy = ref<'price' | 'count'>('price')
const supplierSortBy = ref<'price' | 'count'>('price')
const aiLoading = ref(false)
const aiResult = ref('')
const now = new Date()
const startOfYear = new Date(now.getFullYear(), 0, 1).getTime()
const productDateRange = ref<(number | string | undefined)[]>([startOfYear, Date.now()])
const supplierDateRange = ref<(number | string | undefined)[]>([startOfYear, Date.now()])
const trendAnalysis = computed(() => {
const prices = trendList.value.map(v => Number(v.price || 0))
const counts = trendList.value.map(v => Number(v.count || 0))
return {
totalPrice: prices.reduce((sum, item) => sum + item, 0),
totalCount: counts.reduce((sum, item) => sum + item, 0),
}
})
function formatPrice(value?: number) {
return Number(value || 0).toFixed(2)
}
function formatCount(value?: number) {
return Number(value || 0).toFixed(2)
}
function formatPercent(value?: number) {
return `${Number(value || 0).toFixed(2)}%`
}
function formatDateTime(value?: string | number | Date) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day} 00:00:00`
}
function handleBack() {
navigateBackPlus()
}
async function getTrendData() {
trendList.value = trendType.value === 'week'
? await getPurchaseTrendByWeek(12)
: await getPurchaseTrendByMonth(12)
}
async function getProductRank() {
const [beginTime, endTime] = productDateRange.value
if (!beginTime || !endTime) {
toast.warning('请先选择完整的产品统计时间')
return
}
productRankList.value = await getPurchaseProductRank(formatDateTime(beginTime), formatDateTime(endTime).replace('00:00:00', '23:59:59'), 10, productSortBy.value)
}
async function getSupplierRank() {
const [beginTime, endTime] = supplierDateRange.value
if (!beginTime || !endTime) {
toast.warning('请先选择完整的供应商统计时间')
return
}
supplierRankList.value = await getPurchaseSupplierRank(formatDateTime(beginTime), formatDateTime(endTime).replace('00:00:00', '23:59:59'), 10, supplierSortBy.value)
}
async function handleTrendTypeChange() {
await getTrendData()
}
async function analyzeWithAI() {
aiLoading.value = true
try {
const result = await analyzePurchaseStatistics({
summary: summary.value,
trendData: trendList.value,
productRankList: productRankList.value,
supplierRankList: supplierRankList.value,
trendType: trendType.value,
productRankSortBy: productSortBy.value,
supplierRankSortBy: supplierSortBy.value,
})
aiResult.value = result?.data || result || ''
} catch {
aiResult.value = [
'采购数据分析',
`1. 当前累计净采购金额为 ¥${formatPrice(trendAnalysis.value.totalPrice)}`,
`2. 当前累计净采购数量为 ${formatCount(trendAnalysis.value.totalCount)}`,
`3. 产品榜首为 ${productRankList.value[0]?.productName || '暂无'}`,
`4. 供应商榜首为 ${supplierRankList.value[0]?.supplierName || '暂无'}`,
].join('\n')
} finally {
aiLoading.value = false
}
}
onMounted(async () => {
summary.value = await getPurchaseSummary()
await Promise.all([getTrendData(), getProductRank(), getSupplierRank()])
})
</script>
<style lang="scss" scoped>
</style>