refactor: 需求10/23 前端改为 ERP 口径,修正 ERP 可用性误判
## 更正
此前误判 ERP 模块被禁用(实际是测试路径 /erp/statistics/purchase 写错,
真实映射为 /erp/purchase-statistics),据此用 SRM 报价做了成本分析。
现按 ERP 可用状态重做。
## 变更
### 需求23 成本分析
- 新增 src/views/erp/purchase/statistics/price-analysis.vue
· 数据源 /erp/purchase-statistics/price(已审核采购订单聚合)
· 列:物料编码/名称、采购量、加权均价、最低价、最高价、最近价、上次价、价格变动率
· 顶部汇总:采购量合计、均价均值、物料数、涨价物料数(由查询结果计算,不依赖表格内部状态)
- src/api/erp/statistics/purchase/index.ts 新增 getPurchasePriceAnalysis 及类型
- 删除 srm/statistics/cost-analysis.vue 与 SRM 成本分析 api,避免同一需求两套口径
### 需求10 退单管理
- src/api/aftersales/cancellation.ts 新增 saleOrderId
- 受理表单新增「ERP 销售订单」字段(填写后审批通过会联动作废)
- 提交时携带 saleOrderId
## 验证
- pnpm build:✓ built in 36.94s(首次因 Node 内存不足触发 0xC0000005,
加 --max-old-space-size=8192 后成功,非代码问题)
- 生成 chunk:price-analysis 4KB、cancellation 5KB、execution 5KB
- pnpm typecheck:本次改动文件 0 错误
(仅既有 erp/purchase/statistics/uncompleted.vue 有历史类型错误)
- 后端联调:需求23 接口 15 条物料;需求10 成功作废订单(status=30)、
有 MPS/发货通知时正确阻断、重复作废被拒
| | |
| | | ticketId?: number; |
| | | customerId?: number; |
| | | contractId?: number; |
| | | /** ERP éå®è®¢åç¼å·ï¼å¡«åå审æ¹éè¿ä¼èå¨ä½åº */ |
| | | saleOrderId?: number; |
| | | saleOrderNo?: string; |
| | | /** éè´§ç±»åï¼1 鿬¾éè´§ 2 æ¢è´§ 3 ä»
鿬¾ */ |
| | | returnType?: number; |
| | |
| | | return requestClient.get< |
| | | PageResult<AftersalesCancellationApi.Cancellation> |
| | | >('/aftersales/cancellation/page', { params }); |
| | | } |
| | | } |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace ErpPurchaseStatisticsApi { |
| | |
| | | time: string; // æ¶é´ |
| | | price: number; // éè´éé¢ |
| | | } |
| | | |
| | | /** éè´ä»·æ ¼åæï¼æç©æèåå·²å®¡æ ¸éè´è®¢åï¼ */ |
| | | export interface PurchasePriceAnalysis { |
| | | productId?: number; |
| | | productBarCode?: string; |
| | | productName?: string; |
| | | /** éè´éï¼è®¢åæç»æ°éåè®¡ï¼ */ |
| | | totalQuantity?: number; |
| | | /** å æåä»· = Σ(åä»·Ãæ°é) / Σæ°é */ |
| | | avgPrice?: number; |
| | | minPrice?: number; |
| | | maxPrice?: number; |
| | | latestPrice?: number; |
| | | previousPrice?: number; |
| | | /** ä»·æ ¼åå¨çï¼%ï¼ */ |
| | | priceChangeRate?: number; |
| | | } |
| | | } |
| | | |
| | | /** è·å¾éè´ç»è®¡ */ |
| | |
| | | ErpPurchaseStatisticsApi.PurchaseTimeSummaryRespVO[] |
| | | >('/erp/purchase-statistics/time-summary'); |
| | | } |
| | | |
| | | /** éè´ä»·æ ¼åæï¼æç©æèåå·²å®¡æ ¸éè´è®¢åï¼ç¨äºéè´ææ¬åæï¼ */ |
| | | export function getPurchasePriceAnalysis( |
| | | params: PageParam & { orderTime?: string[]; productId?: number }, |
| | | ) { |
| | | return requestClient.get< |
| | | PageResult<ErpPurchaseStatisticsApi.PurchasePriceAnalysis> |
| | | >('/erp/purchase-statistics/price', { params }); |
| | | } |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace SrmStatisticsApi { |
| | |
| | | supplierCount?: number; |
| | | period?: string; |
| | | } |
| | | |
| | | /** æ¥ä»·æç»é¡¹ */ |
| | | export interface CostQuoteItem { |
| | | supplierId?: number; |
| | | supplierName?: string; |
| | | quotePrice?: number; |
| | | taxRate?: number; |
| | | deliveryCycle?: number; |
| | | quoteTime?: string; |
| | | } |
| | | |
| | | /** ç©æææ¬åæè¡ */ |
| | | export interface CostAnalysis { |
| | | productId?: number; |
| | | productCode?: string; |
| | | productName?: string; |
| | | productSpec?: string; |
| | | unit?: string; |
| | | totalQuantity?: number; |
| | | quoteCount?: number; |
| | | supplierCount?: number; |
| | | weightedAvgPrice?: number; |
| | | minPrice?: number; |
| | | maxPrice?: number; |
| | | estimatedPrice?: number; |
| | | deviationRate?: number; |
| | | lastQuoteTime?: string; |
| | | priceChange?: number; |
| | | quotes?: CostQuoteItem[]; |
| | | } |
| | | |
| | | /** ä»·æ ¼è¶å¿ç¹ */ |
| | | export interface CostTrendPoint { |
| | | month?: string; |
| | | avgPrice?: number; |
| | | count?: number; |
| | | } |
| | | } |
| | | |
| | | /** è·åææ ç»è®¡ */ |
| | |
| | | return requestClient.get<SrmStatisticsApi.TenderStatisticsVO>( |
| | | '/srm/statistics/tender', |
| | | { params: { year } }, |
| | | ); |
| | | } |
| | | |
| | | /** éè´ææ¬åæï¼åºäºææ éæ±éä¸ä¾åºåæ¥ä»·ï¼ä¸ä¾èµ ERPï¼ */ |
| | | export function getCostAnalysis( |
| | | params: PageParam & { productName?: string }, |
| | | ) { |
| | | return requestClient.get<PageResult<SrmStatisticsApi.CostAnalysis>>( |
| | | '/srm/statistics/cost-analysis', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** ç©æä»·æ ¼è¶å¿ */ |
| | | export function getCostTrend(productId: number) { |
| | | return requestClient.get<SrmStatisticsApi.CostTrendPoint[]>( |
| | | '/srm/statistics/cost-trend', |
| | | { params: { productId } }, |
| | | ); |
| | | } |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderId', |
| | | label: 'ERP éå®è®¢å', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: 'éå¡«ï¼å¡«åå审æ¹éè¿ä¼èå¨ä½åºè®¢å', |
| | | class: 'w-full', |
| | | min: 1, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderNo', |
| | | label: 'éå®è®¢åå·', |
| | | component: 'Input', |
| | | componentProps: { placeholder: 'éå¡«' }, |
| | | componentProps: { placeholder: 'éå¡«ï¼ä»
ä½å±ç¤º' }, |
| | | }, |
| | | { |
| | | fieldName: 'expectedAmount', |
| | |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | } |
| | |
| | | returnType: (values.returnType as number) ?? undefined, |
| | | contractId: (values.contractId as number) ?? undefined, |
| | | customerId: (values.customerId as number) ?? undefined, |
| | | saleOrderId: (values.saleOrderId as number) ?? undefined, |
| | | saleOrderNo: (values.saleOrderNo as string) || undefined, |
| | | expectedAmount: (values.expectedAmount as number) ?? undefined, |
| | | reason: values.reason as string, |
| | |
| | | </div> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { ErpPurchaseStatisticsApi } from '#/api/erp/statistics/purchase'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | |
| | | import { Alert, Card, Col, message, Row, Statistic } from 'ant-design-vue'; |
| | | import { ref } from 'vue'; |
| | | |
| | | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { getPurchasePriceAnalysis } from '#/api/erp/statistics/purchase'; |
| | | |
| | | defineOptions({ name: 'ErpPurchasePriceAnalysis' }); |
| | | |
| | | /** æ°åæ ¼å¼åï¼ç©ºå¼æ¾ç¤º - */ |
| | | function fmt(value?: number, digits = 4) { |
| | | return value === null || value === undefined |
| | | ? '-' |
| | | : Number(value).toFixed(digits); |
| | | } |
| | | |
| | | const columns: VxeTableGridOptions<ErpPurchaseStatisticsApi.PurchasePriceAnalysis>['columns'] = |
| | | [ |
| | | { field: 'productBarCode', title: 'ç©æç¼ç ', width: 150 }, |
| | | { field: 'productName', title: 'ç©æåç§°', minWidth: 180 }, |
| | | { |
| | | field: 'totalQuantity', |
| | | title: 'éè´é', |
| | | width: 130, |
| | | align: 'right', |
| | | formatter: ({ cellValue }) => fmt(cellValue as number, 2), |
| | | }, |
| | | { |
| | | field: 'avgPrice', |
| | | title: 'å æåä»·', |
| | | width: 120, |
| | | align: 'right', |
| | | formatter: ({ cellValue }) => fmt(cellValue as number, 4), |
| | | }, |
| | | { |
| | | field: 'minPrice', |
| | | title: 'æä½ä»·', |
| | | width: 110, |
| | | align: 'right', |
| | | formatter: ({ cellValue }) => fmt(cellValue as number, 4), |
| | | }, |
| | | { |
| | | field: 'maxPrice', |
| | | title: 'æé«ä»·', |
| | | width: 110, |
| | | align: 'right', |
| | | formatter: ({ cellValue }) => fmt(cellValue as number, 4), |
| | | }, |
| | | { |
| | | field: 'latestPrice', |
| | | title: 'æè¿ä»·', |
| | | width: 110, |
| | | align: 'right', |
| | | formatter: ({ cellValue }) => fmt(cellValue as number, 4), |
| | | }, |
| | | { |
| | | field: 'previousPrice', |
| | | title: '䏿¬¡ä»·', |
| | | width: 110, |
| | | align: 'right', |
| | | formatter: ({ cellValue }) => fmt(cellValue as number, 4), |
| | | }, |
| | | { |
| | | field: 'priceChangeRate', |
| | | title: 'ä»·æ ¼åå¨', |
| | | width: 120, |
| | | align: 'right', |
| | | slots: { default: 'priceChange' }, |
| | | }, |
| | | ]; |
| | | |
| | | const [Grid] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: [ |
| | | { |
| | | fieldName: 'productId', |
| | | label: 'ç©æç¼å·', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | class: 'w-full', |
| | | min: 1, |
| | | placeholder: 'æç©æID精确æ¥è¯¢', |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | gridOptions: { |
| | | columns, |
| | | height: 'auto', |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ( |
| | | params: { page: { currentPage: number; pageSize: number } }, |
| | | formValues: Record<string, any>, |
| | | ) => { |
| | | const { page } = params; |
| | | try { |
| | | const result = await getPurchasePriceAnalysis({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | productId: formValues.productId || undefined, |
| | | }); |
| | | const list = result?.list ?? []; |
| | | const withAvg = list.filter( |
| | | (r) => r.avgPrice !== null && r.avgPrice !== undefined, |
| | | ); |
| | | summary.value = { |
| | | totalQuantity: list.reduce((s, r) => s + (r.totalQuantity ?? 0), 0), |
| | | avgPrice: withAvg.length |
| | | ? withAvg.reduce((s, r) => s + (r.avgPrice ?? 0), 0) / |
| | | withAvg.length |
| | | : 0, |
| | | rowCount: list.length, |
| | | risingCount: list.filter((r) => (r.priceChangeRate ?? 0) > 0).length, |
| | | }; |
| | | return result; |
| | | } catch (e: unknown) { |
| | | message.error((e as Error).message || 'æ¥è¯¢å¤±è´¥'); |
| | | summary.value = { |
| | | totalQuantity: 0, |
| | | avgPrice: 0, |
| | | rowCount: 0, |
| | | risingCount: 0, |
| | | }; |
| | | return { list: [], total: 0 }; |
| | | } |
| | | }, |
| | | }, |
| | | }, |
| | | rowConfig: { keyField: 'productId', isHover: true }, |
| | | toolbarConfig: { refresh: true, search: true }, |
| | | }, |
| | | }); |
| | | |
| | | /** æ±æ»ææ ï¼ç±æ¥è¯¢ç»ææ´æ°ï¼é¿å
ä¾èµè¡¨æ ¼å
é¨ç¶æ */ |
| | | const summary = ref({ totalQuantity: 0, avgPrice: 0, rowCount: 0, risingCount: 0 }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <div class="space-y-4"> |
| | | <Card> |
| | | <Alert |
| | | type="info" |
| | | show-icon |
| | | message="å£å¾ï¼ä»
ç»è®¡å·²å®¡æ ¸ï¼status=20ï¼çéè´è®¢åæç»ï¼å æåä»· = Σ(åä»·Ãæ°é) / Σæ°éï¼ä»·æ ¼åå¨ç = (æè¿ä»· â 䏿¬¡ä»·) / 䏿¬¡ä»·ã" |
| | | /> |
| | | <Row :gutter="16" class="mt-4"> |
| | | <Col :span="6"> |
| | | <Statistic title="éè´éå计" :value="fmt(summary.totalQuantity, 2)" /> |
| | | </Col> |
| | | <Col :span="6"> |
| | | <Statistic title="åä»·åå¼" :value="fmt(summary.avgPrice, 4)" /> |
| | | </Col> |
| | | <Col :span="6"> |
| | | <Statistic title="ç©ææ°" :value="summary.rowCount" /> |
| | | </Col> |
| | | <Col :span="6"> |
| | | <Statistic title="æ¶¨ä»·ç©ææ°" :value="summary.risingCount" /> |
| | | </Col> |
| | | </Row> |
| | | </Card> |
| | | |
| | | <Grid table-title="éè´ææ¬åæï¼æç©æèåéè´è®¢åï¼"> |
| | | <template #priceChange="{ row }"> |
| | | <span |
| | | :class=" |
| | | row.priceChangeRate > 0 |
| | | ? 'text-red-500' |
| | | : row.priceChangeRate < 0 |
| | | ? 'text-green-600' |
| | | : '' |
| | | " |
| | | > |
| | | {{ row.priceChangeRate === null || row.priceChangeRate === undefined |
| | | ? '-' |
| | | : `${row.priceChangeRate}%` }} |
| | | </span> |
| | | </template> |
| | | </Grid> |
| | | </div> |
| | | </Page> |
| | | </template> |