xiaoyi
21 小时以前 473a8b1acdad441bd93a81066e385bb1b6f922b7
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/发货通知时正确阻断、重复作废被拒
已添加1个文件
已删除1个文件
已修改5个文件
567 ■■■■■ 文件已修改
src/api/aftersales/cancellation.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/statistics/purchase/index.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/statistics/index.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/cancellation/data.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/cancellation/modules/form.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/statistics/price-analysis.vue 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/statistics/cost-analysis.vue 272 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/cancellation.ts
@@ -21,6 +21,8 @@
    ticketId?: number;
    customerId?: number;
    contractId?: number;
    /** ERP é”€å”®è®¢å•编号;填写后审批通过会联动作废 */
    saleOrderId?: number;
    saleOrderNo?: string;
    /** é€€è´§ç±»åž‹ï¼š1 é€€æ¬¾é€€è´§ 2 æ¢è´§ 3 ä»…退款 */
    returnType?: number;
@@ -106,4 +108,4 @@
  return requestClient.get<
    PageResult<AftersalesCancellationApi.Cancellation>
  >('/aftersales/cancellation/page', { params });
}
}
src/api/erp/statistics/purchase/index.ts
@@ -1,3 +1,5 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ErpPurchaseStatisticsApi {
@@ -14,6 +16,23 @@
    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;
  }
}
/** èŽ·å¾—é‡‡è´­ç»Ÿè®¡ */
@@ -29,3 +48,12 @@
    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 });
}
src/api/srm/statistics/index.ts
@@ -1,5 +1,3 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace SrmStatisticsApi {
@@ -12,43 +10,6 @@
    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;
  }
}
/** èŽ·å–æ‹›æ ‡ç»Ÿè®¡ */
@@ -56,23 +17,5 @@
  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 } },
  );
}
src/views/aftersales/cancellation/data.ts
@@ -202,10 +202,20 @@
      },
    },
    {
      fieldName: 'saleOrderId',
      label: 'ERP é”€å”®è®¢å•',
      component: 'InputNumber',
      componentProps: {
        placeholder: '选填;填写后审批通过会联动作废订单',
        class: 'w-full',
        min: 1,
      },
    },
    {
      fieldName: 'saleOrderNo',
      label: '销售订单号',
      component: 'Input',
      componentProps: { placeholder: '选填' },
      componentProps: { placeholder: '选填,仅作展示' },
    },
    {
      fieldName: 'expectedAmount',
@@ -237,4 +247,4 @@
      },
    },
  ];
}
}
src/views/aftersales/cancellation/modules/form.vue
@@ -83,6 +83,7 @@
      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,
@@ -117,4 +118,4 @@
      </div>
    </template>
  </Modal>
</template>
</template>
src/views/erp/purchase/statistics/price-analysis.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,189 @@
<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>
src/views/srm/statistics/cost-analysis.vue
ÎļþÒÑɾ³ý