feat(erp): 采购/销售明细按含税单价口径联算,采购申请与申请采购单合并入口

- 新增 erpTax 工具:含税总价 = 数量 × 含税单价,税额 = 含税总价 × 税率 ÷ (100 + 税率),不含税金额 = 含税总价 − 税额;采购订单、采购申请、销售订单明细统一使用
- 采购申请单页签合并「申请采购单」,原申请采购单/申请采购页保留旧路由直达
- 供应商合同支持合同附件上传,从供应商档案进入时按供应商筛选
- 采购订单/销售订单打印按含税口径展示

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加4个文件
已修改20个文件
1740 ■■■■■ 文件已修改
src/api/erp/purchase/request/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/supplier-contract/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/erpTax.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/demand/index.vue 265 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/demand/modules/demand-panel.vue 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/data.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/modules/item-form.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/order/modules/print.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/place/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/place/index.vue 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/place/modules/place-panel.vue 283 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/place/modules/purchase-form.vue 369 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/detail/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/request/modules/item-form.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/supplier-contract/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/supplier-contract/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/supplier-contract/modules/form.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/item-form.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/print.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/request/index.ts
@@ -8,6 +8,7 @@
    id?: number; // ç¼–号
    no?: string; // ç”³è¯·ç¼–号
    status?: number; // çŠ¶æ€ï¼š0未提交 10审批中 20审核通过 30审核不通过 40已取消
    inStatus?: number; // å…¥åº“状态:0未入库 1部分入库 2全部入库
    processInstanceId?: string; // å·¥ä½œæµç¼–号
    requestUserId?: number; // ç”³è¯·äººç¼–号
    requestUserName?: string; // ç”³è¯·äººåç§°
@@ -27,7 +28,6 @@
    productNames?: string; // äº§å“åç§°åˆ—表
    orderId?: number; // ç”Ÿæˆçš„采购订单编号
    orderNo?: string; // ç”Ÿæˆçš„采购订单号
    processInstanceId?: number; // å®¡æ‰¹æµç¨‹å®žä¾‹ç¼–号
    items?: PurchaseRequestItem[]; // ç”³è¯·æ˜Žç»†
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
src/api/erp/purchase/supplier-contract/index.ts
@@ -19,6 +19,8 @@
    orderCount?: number; // å…³è”采购订单数量
    orderAmount?: number; // å…³è”采购订单金额合计
    remark?: string; // å¤‡æ³¨
    blobIds?: number[]; // é™„件编号列表(新增/修改时提交)
    attachmentList?: { id: number; name?: string; url?: string }[]; // é™„件列表(详情返回)
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
}
src/utils/erpTax.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
import { erpPriceMultiply } from '@vben/utils';
/** ERP æ˜Žç»†è¡Œä¸­ä¸Žé‡‘额、税额相关的字段 */
export interface ErpTaxLine {
  /** æ•°é‡ */
  count?: null | number;
  /** äº§å“å•价(含税单价) */
  productPrice?: null | number;
  /** ç¨ŽçŽ‡ï¼Œç™¾åˆ†æ¯”ï¼Œä¾‹å¦‚ 13 */
  taxPercent?: null | number;
  /** ç¨Žé¢ */
  taxPrice?: null | number;
  /** ä¸å«ç¨Žé‡‘额 */
  totalProductPrice?: null | number;
  /** å«ç¨Žæ€»ä»· */
  totalPrice?: null | number;
}
/**
 * æŒ‰ã€Œäº§å“å•价为含税单价」的口径重算明细行金额
 *
 * å«ç¨Žæ€»ä»· = æ•°é‡ Ã— å«ç¨Žå•ä»·
 * ç¨Žé¢ = å«ç¨Žæ€»ä»· Ã— ç¨Žçއ Ã·ï¼ˆ100 + ç¨ŽçŽ‡ï¼‰
 * ä¸å«ç¨Žé‡‘额 = å«ç¨Žæ€»ä»· âˆ’ ç¨Žé¢
 */
export function calcTaxIncludedLine<T extends ErpTaxLine>(row: T): T {
  const totalPrice =
    erpPriceMultiply(Number(row.productPrice ?? 0), Number(row.count ?? 0)) ??
    0;
  const taxPercent = Number(row.taxPercent ?? 0);
  const taxPrice =
    taxPercent > 0
      ? Number(((totalPrice * taxPercent) / (100 + taxPercent)).toFixed(2))
      : 0;
  row.totalPrice = totalPrice;
  row.taxPrice = taxPrice;
  row.totalProductPrice = Number((totalPrice - taxPrice).toFixed(2));
  return row;
}
src/utils/index.ts
@@ -1,5 +1,6 @@
import type { Recordable } from '@vben/types';
export * from './erpTax';
export * from './import-result';
export * from './rangePickerProps';
export * from './routerHelper';
src/views/erp/purchase/demand/index.vue
@@ -1,270 +1,13 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseDemandApi } from '#/api/erp/purchase/demand';
import { Page } from '@vben/common-ui';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import DemandPanel from './modules/demand-panel.vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  createPurchaseOrderByDemand,
  deleteDemand,
  exportDemand,
  getDemandPage,
} from '#/api/erp/purchase/demand';
import { $t } from '#/locales';
import { DEMAND_STATUS, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import Print from './modules/print.vue';
/** ç”³è¯·é‡‡è´­å•(需求单):手工/生产计划需求 â†’ æ‰¹é‡é‡‡è´­ä¸‹å•生成采购订单 */
/** ç”³è¯·é‡‡è´­å•(4.4):已并入「采购申请单」页签,此页面保留用于旧路由直达 */
defineOptions({ name: 'ErpPurchaseDemand' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const checkedRows = ref<ErpPurchaseDemandApi.Demand[]>([]);
/** æ‰“印组件(4.4④ æ‰“印需求单) */
const printRef = ref<InstanceType<typeof Print>>();
/** æ‰“印需求单 */
function handlePrint(row: ErpPurchaseDemandApi.Demand) {
  printRef.value?.print(row.id!);
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportDemand(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '申请采购单.xlsx', source: data });
}
/** æ–°å¢žéœ€æ±‚单 */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑需求单 */
function handleEdit(row: ErpPurchaseDemandApi.Demand) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹è¯¦æƒ… */
function handleDetail(row: ErpPurchaseDemandApi.Demand) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤éœ€æ±‚单 */
async function handleDelete(row: ErpPurchaseDemandApi.Demand) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await deleteDemand(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** é‡‡è´­ä¸‹å•(单条或批量):所选需求单必须属于同一供货商 */
async function handlePurchase(rows: ErpPurchaseDemandApi.Demand[]) {
  const unpurchased = rows.filter(
    (row) => row.status === DEMAND_STATUS.UNPURCHASED,
  );
  if (unpurchased.length === 0) {
    message.warning('所选需求单均已采购,无需重复下单');
    return;
  }
  if (unpurchased.length !== rows.length) {
    message.warning(
      `所选 ${rows.length} æ¡éœ€æ±‚单中,${rows.length - unpurchased.length} æ¡å·²é‡‡è´­ï¼Œä»…对剩余的 ${unpurchased.length} æ¡ä¸‹å•`,
    );
  }
  const noSupplier = unpurchased.find((row) => !row.supplierId);
  if (noSupplier) {
    message.error(
      `需求单[${noSupplier.no}]未选择供货商,请先「编辑」补齐供货商后再采购下单`,
    );
    return;
  }
  const supplierNames = new Set(unpurchased.map((row) => row.supplierName));
  if (supplierNames.size > 1) {
    message.error(
      `所选需求单分属多个供货商(${[...supplierNames].join('、')}),一次只能对同一供货商的需求单合并下单,请分批操作`,
    );
    return;
  }
  await confirm(
    `确认为供货商[${[...supplierNames][0]}]的 ${unpurchased.length} æ¡éœ€æ±‚单生成采购订单吗?`,
  );
  const hideLoading = message.loading({
    content: '正在生成采购订单...',
    duration: 0,
  });
  try {
    const result = await createPurchaseOrderByDemand(
      unpurchased.map((row) => row.id!),
    );
    message.success({
      content: `已生成采购订单[${result.orderNo}],可在「查看相关订单」中追溯`,
      duration: 5,
    });
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æ‰¹é‡é‡‡è´­ä¸‹å•:取表格勾选行 */
function handleBatchPurchase() {
  handlePurchase(checkedRows.value);
}
/** æŸ¥çœ‹ç›¸å…³é‡‡è´­è®¢å• */
function handleViewOrder(row: ErpPurchaseDemandApi.Demand) {
  router.push({
    name: 'ErpPurchaseOrder',
    query: { id: row.orderId },
  });
}
function handleRowCheckboxChange({
  records,
}: {
  records: ErpPurchaseDemandApi.Demand[];
}) {
  checkedRows.value = records;
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getDemandPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true, custom: true },
  } as VxeTableGridOptions<ErpPurchaseDemandApi.Demand>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Print ref="printRef" />
    <Grid table-title="申请采购单列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['申请采购单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['erp:purchase-demand:create'],
              onClick: handleCreate,
            },
            {
              label: '批量采购下单',
              type: 'primary',
              icon: ACTION_ICON.AUDIT,
              disabled: isEmpty(checkedRows),
              auth: ['erp:purchase-demand:purchase'],
              onClick: handleBatchPurchase,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['erp:purchase-demand:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['erp:purchase-demand:update'],
              ifShow: () => row.status === DEMAND_STATUS.UNPURCHASED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '采购下单',
              type: 'link',
              auth: ['erp:purchase-demand:purchase'],
              ifShow: () => row.status === DEMAND_STATUS.UNPURCHASED,
              onClick: handlePurchase.bind(null, [row]),
            },
            {
              label: '打印',
              type: 'link',
              icon: 'lucide:printer',
              onClick: handlePrint.bind(null, row),
            },
            {
              label: '查看相关订单',
              type: 'link',
              ifShow: () => !!row.orderId,
              onClick: handleViewOrder.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['erp:purchase-demand:delete'],
              ifShow: () => row.status === DEMAND_STATUS.UNPURCHASED,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.no]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
    <DemandPanel />
  </Page>
</template>
src/views/erp/purchase/demand/modules/demand-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,268 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseDemandApi } from '#/api/erp/purchase/demand';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { confirm, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  createPurchaseOrderByDemand,
  deleteDemand,
  exportDemand,
  getDemandPage,
} from '#/api/erp/purchase/demand';
import { $t } from '#/locales';
import { DEMAND_STATUS, useGridColumns, useGridFormSchema } from '../data';
import Form from './form.vue';
import Print from './print.vue';
/** ç”³è¯·é‡‡è´­å•(需求单):手工/生产计划需求 â†’ æ‰¹é‡é‡‡è´­ä¸‹å•生成采购订单 */
defineOptions({ name: 'ErpPurchaseDemandPanel' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const checkedRows = ref<ErpPurchaseDemandApi.Demand[]>([]);
/** æ‰“印组件(4.4④ æ‰“印需求单) */
const printRef = ref<InstanceType<typeof Print>>();
/** æ‰“印需求单 */
function handlePrint(row: ErpPurchaseDemandApi.Demand) {
  printRef.value?.print(row.id!);
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportDemand(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '申请采购单.xlsx', source: data });
}
/** æ–°å¢žéœ€æ±‚单 */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑需求单 */
function handleEdit(row: ErpPurchaseDemandApi.Demand) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹è¯¦æƒ… */
function handleDetail(row: ErpPurchaseDemandApi.Demand) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤éœ€æ±‚单 */
async function handleDelete(row: ErpPurchaseDemandApi.Demand) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await deleteDemand(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** é‡‡è´­ä¸‹å•(单条或批量):所选需求单必须属于同一供货商 */
async function handlePurchase(rows: ErpPurchaseDemandApi.Demand[]) {
  const unpurchased = rows.filter(
    (row) => row.status === DEMAND_STATUS.UNPURCHASED,
  );
  if (unpurchased.length === 0) {
    message.warning('所选需求单均已采购,无需重复下单');
    return;
  }
  if (unpurchased.length !== rows.length) {
    message.warning(
      `所选 ${rows.length} æ¡éœ€æ±‚单中,${rows.length - unpurchased.length} æ¡å·²é‡‡è´­ï¼Œä»…对剩余的 ${unpurchased.length} æ¡ä¸‹å•`,
    );
  }
  const noSupplier = unpurchased.find((row) => !row.supplierId);
  if (noSupplier) {
    message.error(
      `需求单[${noSupplier.no}]未选择供货商,请先「编辑」补齐供货商后再采购下单`,
    );
    return;
  }
  const supplierNames = new Set(unpurchased.map((row) => row.supplierName));
  if (supplierNames.size > 1) {
    message.error(
      `所选需求单分属多个供货商(${[...supplierNames].join('、')}),一次只能对同一供货商的需求单合并下单,请分批操作`,
    );
    return;
  }
  await confirm(
    `确认为供货商[${[...supplierNames][0]}]的 ${unpurchased.length} æ¡éœ€æ±‚单生成采购订单吗?`,
  );
  const hideLoading = message.loading({
    content: '正在生成采购订单...',
    duration: 0,
  });
  try {
    const result = await createPurchaseOrderByDemand(
      unpurchased.map((row) => row.id!),
    );
    message.success({
      content: `已生成采购订单[${result.orderNo}],可在「查看相关订单」中追溯`,
      duration: 5,
    });
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æ‰¹é‡é‡‡è´­ä¸‹å•:取表格勾选行 */
function handleBatchPurchase() {
  handlePurchase(checkedRows.value);
}
/** æŸ¥çœ‹ç›¸å…³é‡‡è´­è®¢å• */
function handleViewOrder(row: ErpPurchaseDemandApi.Demand) {
  router.push({
    name: 'ErpPurchaseOrder',
    query: { id: row.orderId },
  });
}
function handleRowCheckboxChange({
  records,
}: {
  records: ErpPurchaseDemandApi.Demand[];
}) {
  checkedRows.value = records;
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getDemandPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true, custom: true },
  } as VxeTableGridOptions<ErpPurchaseDemandApi.Demand>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</script>
<template>
  <FormModal @success="handleRefresh" />
  <Print ref="printRef" />
  <Grid table-title="申请采购单列表">
    <template #toolbar-tools>
      <TableAction
        :actions="[
          {
            label: $t('ui.actionTitle.create', ['申请采购单']),
            type: 'primary',
            icon: ACTION_ICON.ADD,
            auth: ['erp:purchase-demand:create'],
            onClick: handleCreate,
          },
          {
            label: '批量采购下单',
            type: 'primary',
            icon: ACTION_ICON.AUDIT,
            disabled: isEmpty(checkedRows),
            auth: ['erp:purchase-demand:purchase'],
            onClick: handleBatchPurchase,
          },
          {
            label: $t('ui.actionTitle.export'),
            type: 'primary',
            icon: ACTION_ICON.DOWNLOAD,
            auth: ['erp:purchase-demand:export'],
            onClick: handleExport,
          },
        ]"
      />
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.detail'),
            type: 'link',
            icon: ACTION_ICON.VIEW,
            onClick: handleDetail.bind(null, row),
          },
          {
            label: $t('common.edit'),
            type: 'link',
            icon: ACTION_ICON.EDIT,
            auth: ['erp:purchase-demand:update'],
            ifShow: () => row.status === DEMAND_STATUS.UNPURCHASED,
            onClick: handleEdit.bind(null, row),
          },
          {
            label: '采购下单',
            type: 'link',
            auth: ['erp:purchase-demand:purchase'],
            ifShow: () => row.status === DEMAND_STATUS.UNPURCHASED,
            onClick: handlePurchase.bind(null, [row]),
          },
          {
            label: '打印',
            type: 'link',
            icon: 'lucide:printer',
            onClick: handlePrint.bind(null, row),
          },
          {
            label: '查看相关订单',
            type: 'link',
            ifShow: () => !!row.orderId,
            onClick: handleViewOrder.bind(null, row),
          },
          {
            label: $t('common.delete'),
            type: 'link',
            danger: true,
            icon: ACTION_ICON.DELETE,
            auth: ['erp:purchase-demand:delete'],
            ifShow: () => row.status === DEMAND_STATUS.UNPURCHASED,
            popConfirm: {
              title: $t('ui.actionMessage.deleteConfirm', [row.no]),
              confirm: handleDelete.bind(null, row),
            },
          },
        ]"
      />
    </template>
  </Grid>
</template>
src/views/erp/purchase/order/data.ts
@@ -68,21 +68,29 @@
      label: '关联合同',
      fieldName: 'contractId',
      component: 'ApiSelect',
      componentProps: (values) => ({
        placeholder: values.supplierId
          ? '请选择该供应商的采购合同'
          : '请先选择供应商',
        allowClear: true,
        showSearch: true,
        disabled: !values.supplierId,
        api: values.supplierId
          ? () => getContractListBySupplier(values.supplierId)
          : () => Promise.resolve([]),
        labelField: 'name',
        valueField: 'id',
      }),
      dependencies: {
        triggerFields: ['supplierId'],
        componentProps: (values) => ({
          placeholder: values.supplierId
            ? '请选择该供应商的采购合同'
            : '请先选择供应商',
          allowClear: true,
          showSearch: true,
          disabled: !values.supplierId,
          // åªåˆ—采购合同,合同模板不能关联订单
          api: () =>
            values.supplierId
              ? getContractListBySupplier(values.supplierId).then((list) =>
                  list.filter((item) => item.type === 1),
                )
              : Promise.resolve([]),
          // ä¾›åº”商变化时强制重新拉取选项
          params: { supplierId: values.supplierId },
          labelFn: (item: { code?: string; name?: string }) =>
            `${item.code || ''} ${item.name || ''}`.trim(),
          labelField: 'name',
          valueField: 'id',
        }),
      },
      help: '仅可选择当前供应商名下的合同;合同与供应商不匹配时保存会被拒绝',
    },
@@ -280,14 +288,14 @@
    },
    {
      field: 'productPrice',
      title: '产品单价',
      title: '含税单价',
      minWidth: 120,
      fixed: 'right',
      slots: { default: 'productPrice' },
    },
    {
      field: 'totalProductPrice',
      title: '金额',
      title: '不含税金额',
      minWidth: 120,
      fixed: 'right',
      formatter: 'formatAmount2',
@@ -308,7 +316,7 @@
    },
    {
      field: 'totalPrice',
      title: '税额合计',
      title: '含税总价',
      minWidth: 120,
      fixed: 'right',
      formatter: 'formatAmount2',
src/views/erp/purchase/order/index.vue
@@ -8,7 +8,7 @@
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { message, Tabs, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -19,6 +19,7 @@
} from '#/api/erp/purchase/order';
import { $t } from '#/locales';
import PlacePanel from '../place/modules/place-panel.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
@@ -28,6 +29,16 @@
defineOptions({ name: 'ErpPurchaseOrder' });
const route = useRoute();
/**
 * é¡µç­¾ï¼š4.2 é‡‡è´­ä¸‹å•与 4.5 é‡‡è´­è®¢å•共用一张订单,
 * åˆå¹¶ä¸ºä¸€ä¸ªèœå•,避免两个入口重复。
 */
const activeTab = ref('order');
const TAB_ITEMS = [
  { key: 'order', label: '采购订单' },
  { key: 'place', label: '采购下单' },
];
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -143,6 +154,7 @@
    toolbarConfig: {
      refresh: true,
      search: true,
      custom: true,
    },
  } as VxeTableGridOptions<ErpPurchaseOrderApi.PurchaseOrder>,
  gridEvents: {
@@ -164,7 +176,11 @@
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Print ref="printRef" />
    <Grid table-title="采购订单列表">
    <div class="mx-4 mb-2">
      <Tabs v-model:active-key="activeTab" :items="TAB_ITEMS" />
    </div>
    <PlacePanel v-if="activeTab === 'place'" />
    <Grid v-show="activeTab === 'order'" table-title="采购订单列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
src/views/erp/purchase/order/modules/item-form.vue
@@ -13,6 +13,7 @@
import { Input, InputNumber } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { calcTaxIncludedLine } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { useFormItemColumns } from '../data';
@@ -161,7 +162,7 @@
  handleRowChange(row);
}
/** å¤„理行数据变更 */
/** å¤„理行数据变更:数量、含税单价、税率变化后立即重算金额 */
function handleRowChange(row: any) {
  const index = tableData.value.findIndex((item) => item.seq === row.seq);
  if (index === -1) {
@@ -169,16 +170,14 @@
  } else {
    tableData.value[index] = row;
  }
  calcTaxIncludedLine(row);
  emit('update:items', [...tableData.value]);
}
/** åˆå§‹åŒ–行数据 */
function initRow(row: ErpPurchaseOrderApi.PurchaseOrderItem) {
  if (row.productPrice && row.count) {
    row.totalProductPrice = erpPriceMultiply(row.productPrice, row.count) ?? 0;
    row.taxPrice =
      erpPriceMultiply(row.totalProductPrice, (row.taxPercent || 0) / 100) ?? 0;
    row.totalPrice = row.totalProductPrice + row.taxPrice;
    calcTaxIncludedLine(row);
  }
}
@@ -194,7 +193,7 @@
        throw new Error(`第 ${i + 1} è¡Œï¼šäº§å“æ•°é‡ä¸èƒ½ä¸ºç©º`);
      }
      if (!item.productPrice || item.productPrice <= 0) {
        throw new Error(`第 ${i + 1} è¡Œï¼šäº§å“å•价不能为空`);
        throw new Error(`第 ${i + 1} è¡Œï¼šå«ç¨Žå•价不能为空`);
      }
    }
  }
@@ -283,11 +282,11 @@
          <div class="flex space-x-4">
            <span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
            <span>
              é‡‘额:{{ erpPriceInputFormatter(summaries.totalProductPrice) }}
              ä¸å«ç¨Žé‡‘额:{{ erpPriceInputFormatter(summaries.totalProductPrice) }}
            </span>
            <span>税额:{{ erpPriceInputFormatter(summaries.taxPrice) }}</span>
            <span>
              ç¨Žé¢åˆè®¡ï¼š{{ erpPriceInputFormatter(summaries.totalPrice) }}
              å«ç¨Žæ€»ä»·ï¼š{{ erpPriceInputFormatter(summaries.totalPrice) }}
            </span>
          </div>
        </div>
src/views/erp/purchase/order/modules/print.vue
@@ -84,18 +84,19 @@
    : erpPriceMultiply(price, count);
}
/** æ˜Žç»†è¡Œï¼šé‡‘额 = å•ä»· Ã— æ•°é‡ï¼›ç¨Žé¢ = é‡‘额 Ã— ç¨ŽçŽ‡ï¼›å«ç¨Žé‡‘é¢ = é‡‘额 + ç¨Žé¢ */
/** æ˜Žç»†è¡Œï¼ˆå•价为含税单价):含税总价 = å•ä»· Ã— æ•°é‡ï¼›ç¨Žé¢ = å«ç¨Žæ€»ä»· Ã— ç¨Žçއ Ã·ï¼ˆ100 + ç¨ŽçŽ‡ï¼‰ï¼›ä¸å«ç¨Žé‡‘é¢ = å«ç¨Žæ€»ä»· âˆ’ ç¨Žé¢ */
const printRows = computed(() =>
  (printData.value.items || []).map((item) => {
    const totalProductPrice =
      item.totalProductPrice ??
      multiplyPrice(item.productPrice, item.count) ??
      0;
    const totalPrice =
      item.totalPrice ?? multiplyPrice(item.productPrice, item.count) ?? 0;
    const taxPercent = item.taxPercent ?? 0;
    const taxPrice =
      item.taxPrice ??
      multiplyPrice(totalProductPrice, (item.taxPercent ?? 0) / 100) ??
      0;
    const totalPrice = item.totalPrice ?? totalProductPrice + taxPrice;
      (taxPercent > 0
        ? Number(((totalPrice * taxPercent) / (100 + taxPercent)).toFixed(2))
        : 0);
    const totalProductPrice =
      item.totalProductPrice ?? Number((totalPrice - taxPrice).toFixed(2));
    return { ...item, totalProductPrice, taxPrice, totalPrice };
  }),
);
@@ -223,10 +224,10 @@
              æ•°é‡
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold">
              å•ä»·(元)
              å«ç¨Žå•ä»·(元)
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold">
              é‡‘额(元)
              ä¸å«ç¨Žé‡‘额(元)
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold">
              ç¨Žçއ(%)
src/views/erp/purchase/place/data.ts
@@ -26,15 +26,6 @@
        allowClear: true,
      },
    },
    {
      fieldName: 'contactName',
      label: '联系人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入联系人',
        allowClear: true,
      },
    },
  ];
}
src/views/erp/purchase/place/index.vue
@@ -1,286 +1,13 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SrmSupplierApi } from '#/api/srm/supplier';
import { Page } from '@vben/common-ui';
import { ref } from 'vue';
import PlacePanel from './modules/place-panel.vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportSupplierExcel, getSupplierPage } from '#/api/srm/supplier';
import { $t } from '#/locales';
import {
  SUPPLIER_ONLINE_STATUS_CELLTAG,
  SUPPLIER_PURCHASE_CATEGORY_CELLTAG,
  SUPPLIER_STATUS_CELLTAG,
} from '#/views/srm/enums';
import Form from '../order/modules/form.vue';
import { PLACE_CATEGORY_TABS, useGridColumns, useGridFormSchema } from './data';
/** é‡‡è´­ä¸‹å•(4.2):按采购品类切换查看供应商,点击「采购」直接进入下单 */
/** é‡‡è´­ä¸‹å•(4.2):已并入「采购订单」页签,此页面保留用于旧路由直达 */
defineOptions({ name: 'ErpPurchasePlace' });
/** å½“前采购品类标签页:1 ä»£ç†å•†å“ 2 åŽŸè¾…æ–™ */
const activeTab = ref('1');
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºå½“前采购品类的供应商清单(4.2④ æ•°æ®å¯¼å‡ºï¼‰ */
async function handleExport() {
  const data = await exportSupplierExcel({
    ...(await gridApi.formApi.getValues()),
    purchaseCategory: Number(activeTab.value),
  });
  downloadFileFromBlobPart({ fileName: '采购下单清单.xlsx', source: data });
}
/** å½“前标签页的供应商清单(打印用) */
const printRows = ref<SrmSupplierApi.SupplierVO[]>([]);
const printing = ref(false);
/** æ‰“印当前采购品类的供应商清单(4.2④ æ‰“印清单) */
async function handlePrint() {
  const rows = (gridApi.grid.getData() ||
    []) as SrmSupplierApi.SupplierVO[];
  if (rows.length === 0) {
    message.warning('当前没有可打印的供应商数据');
    return;
  }
  printRows.value = rows;
  printing.value = true;
  await new Promise<void>((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
  });
  const removePrintMode = () => {
    document.body.classList.remove('erp-purchase-place-printing');
    printing.value = false;
  };
  document.body.classList.add('erp-purchase-place-printing');
  window.addEventListener('afterprint', removePrintMode, { once: true });
  window.print();
}
/** å“ç±»æ–‡æ¡ˆ */
function categoryLabel(value?: number) {
  return SUPPLIER_PURCHASE_CATEGORY_CELLTAG.labels[value ?? 0] || '未分类';
}
function onlineLabel(value?: number) {
  return SUPPLIER_ONLINE_STATUS_CELLTAG.labels[value ?? 0] || '-';
}
function statusLabel(value?: number) {
  return SUPPLIER_STATUS_CELLTAG.labels[value ?? 0] || '-';
}
/** ç‚¹å‡»ä¾›åº”商「采购」:打开采购订单新建弹窗并带入供应商 */
function handlePlaceOrder(row: SrmSupplierApi.SupplierVO) {
  formModalApi.setData({ formType: 'create', supplierId: row.id }).open();
}
function handleTabChange(key: number | string) {
  activeTab.value = String(key);
  gridApi.query();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getSupplierPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            purchaseCategory: Number(activeTab.value),
            ...formValues,
          }),
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true, custom: true },
  } as VxeTableGridOptions<SrmSupplierApi.SupplierVO>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <div class="mb-2">
      <Tabs
        v-model:active-key="activeTab"
        :items="PLACE_CATEGORY_TABS"
        size="small"
        @change="handleTabChange"
      />
    </div>
    <Grid table-title="采购下单">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['srm:supplier:export'],
              onClick: handleExport,
            },
            {
              label: '打印清单',
              type: 'primary',
              icon: 'lucide:printer',
              onClick: handlePrint,
            },
            {
              label: '刷新',
              type: 'primary',
              icon: ACTION_ICON.REFRESH,
              onClick: handleRefresh,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '采购',
              type: 'link',
              icon: ACTION_ICON.ADD,
              auth: ['erp:purchase-order:create'],
              onClick: handlePlaceOrder.bind(null, row),
            },
          ]"
        />
      </template>
    </Grid>
    <!-- æ‰“印清单(4.2④):仅打印时展示 -->
    <Teleport to="body">
      <div
        v-if="printing"
        id="erpPurchasePlacePrint"
        class="erp-purchase-place-print pointer-events-none fixed left-0 top-0 z-[-1] w-full bg-white text-[#303133] opacity-0"
      >
        <h2 class="m-0 mb-2 text-center text-[1.6em] font-bold leading-[1.2]">
          {{
            activeTab === '2' ? '原辅料采购清单' : '代理商品采购清单'
          }}
        </h2>
        <table class="w-full border-collapse text-[13px] leading-[1.5]">
          <thead>
            <tr>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
                åºå·
              </th>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
                ä¾›åº”商编码
              </th>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
                ä¾›åº”商名称
              </th>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
                é‡‡è´­å“ç±»
              </th>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
                åœ¨çº¿çŠ¶æ€
              </th>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
                è”系人
              </th>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
                è”系电话
              </th>
              <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
                çŠ¶æ€
              </th>
            </tr>
          </thead>
          <tbody>
            <tr
              v-for="(row, index) in printRows"
              :key="row.id || index"
            >
              <td class="border border-solid border-[#dcdfe6] p-2 text-center">
                {{ index + 1 }}
              </td>
              <td class="border border-solid border-[#dcdfe6] p-2">
                {{ row.code || '-' }}
              </td>
              <td class="border border-solid border-[#dcdfe6] p-2">
                {{ row.name || '-' }}
              </td>
              <td class="border border-solid border-[#dcdfe6] p-2 text-center">
                {{ categoryLabel(row.purchaseCategory) }}
              </td>
              <td class="border border-solid border-[#dcdfe6] p-2 text-center">
                {{ onlineLabel(row.onlineStatus) }}
              </td>
              <td class="border border-solid border-[#dcdfe6] p-2">
                {{ row.contactName || '-' }}
              </td>
              <td class="border border-solid border-[#dcdfe6] p-2">
                {{ row.contactMobile || '-' }}
              </td>
              <td class="border border-solid border-[#dcdfe6] p-2 text-center">
                {{ statusLabel(row.status) }}
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </Teleport>
    <PlacePanel />
  </Page>
</template>
<style scoped>
@page {
  margin: 8mm 10mm;
}
@media print {
  :global(body.erp-purchase-place-printing) {
    padding: 0 !important;
    margin: 0 !important;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  :global(body.erp-purchase-place-printing *) {
    visibility: hidden !important;
  }
  :global(body.erp-purchase-place-printing .erp-purchase-place-print),
  :global(body.erp-purchase-place-printing .erp-purchase-place-print *) {
    visibility: visible !important;
  }
  :global(body.erp-purchase-place-printing .erp-purchase-place-print) {
    position: absolute;
    top: 0;
    left: 0;
    z-index: auto;
    box-sizing: border-box;
    width: 100%;
    padding: 0 !important;
    margin: 0 !important;
    pointer-events: auto;
    opacity: 1;
  }
}
</style>
src/views/erp/purchase/place/modules/place-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,283 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SrmSupplierApi } from '#/api/srm/supplier';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportSupplierExcel, getSupplierPage } from '#/api/srm/supplier';
import { $t } from '#/locales';
import {
  SUPPLIER_ONLINE_STATUS_CELLTAG,
  SUPPLIER_PURCHASE_CATEGORY_CELLTAG,
  SUPPLIER_STATUS_CELLTAG,
} from '#/views/srm/enums';
import Form from './purchase-form.vue';
import { PLACE_CATEGORY_TABS, useGridColumns, useGridFormSchema } from '../data';
/** é‡‡è´­ä¸‹å•(4.2):按采购品类切换查看供应商,点击「采购」直接进入下单 */
defineOptions({ name: 'ErpPurchasePlacePanel' });
/** å½“前采购品类标签页:1 ä»£ç†å•†å“ 2 åŽŸè¾…æ–™ */
const activeTab = ref('1');
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºå½“前采购品类的供应商清单(4.2④ æ•°æ®å¯¼å‡ºï¼‰ */
async function handleExport() {
  const data = await exportSupplierExcel({
    ...(await gridApi.formApi.getValues()),
    purchaseCategory: Number(activeTab.value),
  });
  downloadFileFromBlobPart({ fileName: '采购下单清单.xlsx', source: data });
}
/** å½“前标签页的供应商清单(打印用) */
const printRows = ref<SrmSupplierApi.SupplierVO[]>([]);
const printing = ref(false);
/** æ‰“印当前采购品类的供应商清单(4.2④ æ‰“印清单) */
async function handlePrint() {
  const rows = (gridApi.grid.getData() || []) as SrmSupplierApi.SupplierVO[];
  if (rows.length === 0) {
    message.warning('当前没有可打印的供应商数据');
    return;
  }
  printRows.value = rows;
  printing.value = true;
  await new Promise<void>((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
  });
  const removePrintMode = () => {
    document.body.classList.remove('erp-purchase-place-printing');
    printing.value = false;
  };
  document.body.classList.add('erp-purchase-place-printing');
  window.addEventListener('afterprint', removePrintMode, { once: true });
  window.print();
}
/** å“ç±»æ–‡æ¡ˆ */
function categoryLabel(value?: number) {
  return SUPPLIER_PURCHASE_CATEGORY_CELLTAG.labels[value ?? 0] || '未分类';
}
function onlineLabel(value?: number) {
  return SUPPLIER_ONLINE_STATUS_CELLTAG.labels[value ?? 0] || '-';
}
function statusLabel(value?: number) {
  return SUPPLIER_STATUS_CELLTAG.labels[value ?? 0] || '-';
}
/** ç‚¹å‡»ä¾›åº”商「采购」:确认采购品类、品牌,录入数量、单价后生成订单 */
function handlePlaceOrder(row: SrmSupplierApi.SupplierVO) {
  formModalApi
    .setData({
      supplier: row,
      purchaseCategory: Number(activeTab.value),
    })
    .open();
}
function handleTabChange(key: number | string) {
  activeTab.value = String(key);
  gridApi.query();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getSupplierPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            purchaseCategory: Number(activeTab.value),
            ...formValues,
          }),
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { refresh: true, search: true, custom: true },
  } as VxeTableGridOptions<SrmSupplierApi.SupplierVO>,
});
</script>
<template>
  <FormModal @success="handleRefresh" />
  <div class="mb-2">
    <Tabs
      v-model:active-key="activeTab"
      :items="PLACE_CATEGORY_TABS"
      size="small"
      @change="handleTabChange"
    />
  </div>
  <Grid table-title="采购下单">
    <template #toolbar-tools>
      <TableAction
        :actions="[
          {
            label: $t('ui.actionTitle.export'),
            type: 'primary',
            icon: ACTION_ICON.DOWNLOAD,
            auth: ['srm:supplier:export'],
            onClick: handleExport,
          },
          {
            label: '打印清单',
            type: 'primary',
            icon: 'lucide:printer',
            onClick: handlePrint,
          },
          {
            label: '刷新',
            type: 'primary',
            icon: ACTION_ICON.REFRESH,
            onClick: handleRefresh,
          },
        ]"
      />
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: '采购',
            type: 'link',
            icon: ACTION_ICON.ADD,
            auth: ['erp:purchase-order:create'],
            onClick: handlePlaceOrder.bind(null, row),
          },
        ]"
      />
    </template>
  </Grid>
  <!-- æ‰“印清单(4.2④):仅打印时展示 -->
  <Teleport to="body">
    <div
      v-if="printing"
      id="erpPurchasePlacePrint"
      class="erp-purchase-place-print pointer-events-none fixed left-0 top-0 z-[-1] w-full bg-white text-[#303133] opacity-0"
    >
      <h2 class="m-0 mb-2 text-center text-[1.6em] font-bold leading-[1.2]">
        {{ activeTab === '2' ? '原辅料采购清单' : '代理商品采购清单' }}
      </h2>
      <table class="w-full border-collapse text-[13px] leading-[1.5]">
        <thead>
          <tr>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
              åºå·
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
              ä¾›åº”商编码
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
              ä¾›åº”商名称
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
              é‡‡è´­å“ç±»
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
              åœ¨çº¿çŠ¶æ€
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
              è”系人
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-left font-bold">
              è”系电话
            </th>
            <th class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center font-bold">
              çŠ¶æ€
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(row, index) in printRows" :key="row.id || index">
            <td class="border border-solid border-[#dcdfe6] p-2 text-center">
              {{ index + 1 }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2">
              {{ row.code || '-' }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2">
              {{ row.name || '-' }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-center">
              {{ categoryLabel(row.purchaseCategory) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-center">
              {{ onlineLabel(row.onlineStatus) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2">
              {{ row.contactName || '-' }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2">
              {{ row.contactMobile || '-' }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-center">
              {{ statusLabel(row.status) }}
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </Teleport>
</template>
<style scoped>
@page {
  margin: 8mm 10mm;
}
@media print {
  :global(body.erp-purchase-place-printing) {
    padding: 0 !important;
    margin: 0 !important;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  :global(body.erp-purchase-place-printing *) {
    visibility: hidden !important;
  }
  :global(body.erp-purchase-place-printing .erp-purchase-place-print),
  :global(body.erp-purchase-place-printing .erp-purchase-place-print *) {
    visibility: visible !important;
  }
  :global(body.erp-purchase-place-printing .erp-purchase-place-print) {
    position: absolute;
    top: 0;
    left: 0;
    z-index: auto;
    box-sizing: border-box;
    width: 100%;
    padding: 0 !important;
    margin: 0 !important;
    pointer-events: auto;
    opacity: 1;
  }
}
</style>
src/views/erp/purchase/place/modules/purchase-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,369 @@
<script lang="ts" setup>
import type { ErpPurchaseOrderApi } from '#/api/erp/purchase/order';
import type { MdmItemApi } from '#/api/mdm/item';
import type { SrmSupplierApi } from '#/api/srm/supplier';
import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
  erpCountInputFormatter,
  erpPriceInputFormatter,
  erpPriceMultiply,
} from '@vben/utils';
import { Descriptions, InputNumber, message, Select } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { createPurchaseOrder } from '#/api/erp/purchase/order';
import { getContractListBySupplier } from '#/api/erp/purchase/supplier-contract';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { SUPPLIER_PURCHASE_CATEGORY_CELLTAG } from '#/views/srm/enums';
defineOptions({ name: 'ErpPurchasePlaceForm' });
const emit = defineEmits(['success']);
/** é‡‡è´­ä¸‹å•明细行 */
interface PlaceItem {
  seq: number;
  productId?: number;
  productName?: string;
  brandName?: string;
  productUnitId?: number;
  productUnitName?: string;
  count?: number;
  productPrice?: number;
  totalProductPrice?: number;
}
const supplier = ref<SrmSupplierApi.SupplierVO>();
const purchaseCategory = ref<number>();
const tableData = ref<PlaceItem[]>([]);
let nextSeq = 1;
/** è¯¥ä¾›åº”商名下可关联的采购合同(合同模板不参与下单关联) */
const contractOptions = ref<
  { label: string; value: number; status?: number }[]
>([]);
const contractId = ref<number>();
/** åŠ è½½ä¾›åº”å•†çš„é‡‡è´­åˆåŒï¼Œä¾›ã€Œå…³è”åˆåŒã€ä¸‹æ‹‰é€‰æ‹© */
async function loadContracts(supplierId?: number) {
  contractId.value = undefined;
  contractOptions.value = [];
  if (!supplierId) {
    return;
  }
  const contracts = (await getContractListBySupplier(supplierId)) || [];
  contractOptions.value = contracts
    .filter((item) => item.type === 1)
    .map((item) => ({
      label: `${item.code || ''} ${item.name || ''}`.trim(),
      value: item.id!,
      status: item.status,
    }));
}
const supplierName = computed(() => supplier.value?.name || '-');
const categoryLabel = computed(() => {
  const value = purchaseCategory.value ?? supplier.value?.purchaseCategory ?? 0;
  return SUPPLIER_PURCHASE_CATEGORY_CELLTAG.labels[value] || '未分类';
});
/** åˆè®¡ï¼šæ•°é‡ã€é‡‘额 */
const summaries = computed(() => ({
  count: tableData.value.reduce((sum, item) => sum + (item.count || 0), 0),
  totalProductPrice: tableData.value.reduce(
    (sum, item) => sum + (item.totalProductPrice || 0),
    0,
  ),
}));
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { type: 'seq', title: '序号', width: 55, fixed: 'left' },
      {
        field: 'productId',
        title: '产品名称',
        minWidth: 220,
        slots: { default: 'productId' },
      },
      {
        field: 'brandName',
        title: '品牌',
        minWidth: 120,
      },
      {
        field: 'productUnitName',
        title: '单位',
        minWidth: 80,
      },
      {
        field: 'count',
        title: '数量',
        minWidth: 110,
        slots: { default: 'count' },
      },
      {
        field: 'productPrice',
        title: '单价',
        minWidth: 110,
        slots: { default: 'productPrice' },
      },
      {
        field: 'totalProductPrice',
        title: '金额',
        minWidth: 110,
        formatter: 'formatAmount2',
      },
      {
        title: '操作',
        width: 60,
        fixed: 'right',
        slots: { default: 'actions' },
      },
    ],
    data: [] as PlaceItem[],
    minHeight: 240,
    autoResize: true,
    border: true,
    rowConfig: { keyField: 'seq', isHover: true },
    pagerConfig: { enabled: false },
    toolbarConfig: { enabled: false },
  },
});
/** é‡æ–°åŠ è½½è¡¨æ ¼æ•°æ® */
async function reloadTable() {
  await nextTick();
  await gridApi.grid.reloadData(tableData.value);
}
/** æ–°å¢žä¸€è¡Œ */
async function handleAdd() {
  tableData.value.push({ seq: nextSeq++ });
  await reloadTable();
}
/** åˆ é™¤ä¸€è¡Œ */
async function handleDelete(row: PlaceItem) {
  const index = tableData.value.findIndex((item) => item.seq === row.seq);
  if (index !== -1) {
    tableData.value.splice(index, 1);
  }
  await reloadTable();
}
/** é€‰æ‹©äº§å“åŽå¸¦å‡ºå“ç‰Œã€å•位、参考单价 */
async function handleProductChange(
  item: MdmItemApi.Item | undefined,
  row: PlaceItem,
) {
  if (!item?.id) {
    return;
  }
  row.productId = item.id;
  row.productName = item.name;
  row.brandName = item.brandName;
  row.productUnitId = item.unitMeasureId;
  row.productUnitName = item.unitMeasureName;
  row.productPrice = item.purchasePrice || 0;
  row.count = row.count || 1;
  handlePriceChange(row);
}
/** æ•°é‡ / å•价变化后重算行金额 */
function handlePriceChange(row: PlaceItem) {
  row.totalProductPrice =
    erpPriceMultiply(row.productPrice || 0, row.count || 0) ?? 0;
  void reloadTable();
}
/** æ ¡éªŒæ˜Žç»† */
function validateItems() {
  if (tableData.value.length === 0) {
    throw new Error('请至少添加一行采购产品');
  }
  tableData.value.forEach((item, index) => {
    if (!item.productId) {
      throw new Error(`第 ${index + 1} è¡Œï¼šè¯·é€‰æ‹©äº§å“`);
    }
    if (!item.count || item.count <= 0) {
      throw new Error(`第 ${index + 1} è¡Œï¼šæ•°é‡å¿…须大于 0`);
    }
    if (!item.productPrice || item.productPrice <= 0) {
      throw new Error(`第 ${index + 1} è¡Œï¼šå•价必须大于 0`);
    }
  });
}
/** æäº¤ç”Ÿæˆé‡‡è´­è®¢å• */
async function submit() {
  validateItems();
  const items: ErpPurchaseOrderApi.PurchaseOrderItem[] =
    tableData.value.map((item) => ({
      productId: item.productId,
      productUnitId: item.productUnitId,
      productPrice: item.productPrice,
      count: item.count,
      taxPercent: 0,
    }));
  await createPurchaseOrder({
    supplierId: supplier.value!.id!,
    contractId: contractId.value,
    orderTime: formatNow(),
    discountPercent: 0,
    depositPrice: 0,
    items,
  });
  await modalApi.close();
  emit('success');
  message.success('采购订单已生成,并已提交审批');
}
/** å½“前时间(yyyy-MM-dd HH:mm:ss) */
function formatNow() {
  const now = new Date();
  const pad = (value: number) => String(value).padStart(2, '0');
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
}
/** å¼¹çª— */
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    modalApi.lock();
    try {
      await submit();
    } catch (error: any) {
      message.error(error.message || '采购下单失败');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      tableData.value = [];
      supplier.value = undefined;
      contractId.value = undefined;
      contractOptions.value = [];
      return;
    }
    const data = modalApi.getData<{
      supplier: SrmSupplierApi.SupplierVO;
      purchaseCategory?: number;
    }>();
    supplier.value = data?.supplier;
    purchaseCategory.value =
      data?.purchaseCategory ?? data?.supplier?.purchaseCategory;
    await loadContracts(supplier.value?.id);
    nextSeq = 1;
    tableData.value = [{ seq: nextSeq++ }];
    await reloadTable();
  },
});
</script>
<template>
  <Modal
    :title="`采购下单 - ${supplierName}`"
    class="w-4/5"
    :show-confirm-button="true"
    confirm-text="提交生成订单"
  >
    <Descriptions
      class="mx-3 mb-2"
      :column="2"
      size="small"
      bordered
    >
      <Descriptions.Item label="供应商">{{ supplierName }}</Descriptions.Item>
      <Descriptions.Item label="采购品类">
        {{ categoryLabel }}
      </Descriptions.Item>
    </Descriptions>
    <div class="mx-3 mb-2 flex items-center gap-2">
      <span class="shrink-0 text-sm">关联合同:</span>
      <Select
        v-model:value="contractId"
        allow-clear
        show-search
        :options="contractOptions"
        option-filter-prop="label"
        class="w-96"
        placeholder="可选:选择该供应商的采购合同,用于合同执行进度统计"
      />
      <span
        v-if="contractOptions.length === 0"
        class="text-xs text-muted-foreground"
      >
        è¯¥ä¾›åº”商暂无采购合同,可先在「合同管理」中创建
      </span>
    </div>
    <Grid class="mx-3 w-auto">
      <template #productId="{ row }">
        <MdmItemSelect
          v-model:model-value="row.productId"
          :label="row.productName"
          placeholder="请选择产品"
          @change="(item) => handleProductChange(item, row)"
        />
      </template>
      <template #count="{ row }">
        <InputNumber
          v-model:value="row.count"
          :min="0"
          :precision="3"
          @change="handlePriceChange(row)"
        />
      </template>
      <template #productPrice="{ row }">
        <InputNumber
          v-model:value="row.productPrice"
          :min="0"
          :precision="2"
          @change="handlePriceChange(row)"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '删除',
              type: 'link',
              danger: true,
              popConfirm: {
                title: '确认删除该产品吗?',
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
      <template #bottom>
        <div class="mt-2 rounded border border-border bg-muted p-2">
          <div class="flex justify-between text-sm text-muted-foreground">
            <span class="font-medium text-foreground">合计:</span>
            <div class="flex space-x-4">
              <span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
              <span>
                é‡‘额:{{ erpPriceInputFormatter(summaries.totalProductPrice) }}
              </span>
            </div>
          </div>
        </div>
        <TableAction
          class="mt-2 flex justify-center"
          :actions="[
            {
              label: '添加采购产品',
              type: 'default',
              onClick: handleAdd,
            },
          ]"
        />
      </template>
    </Grid>
  </Modal>
</template>
src/views/erp/purchase/request/data.ts
@@ -222,14 +222,14 @@
    },
    {
      field: 'productPrice',
      title: '单价(参考价)',
      title: '含税单价(参考价)',
      minWidth: 120,
      fixed: 'right',
      slots: { default: 'productPrice' },
    },
    {
      field: 'totalPrice',
      title: '预估金额',
      title: '预估含税总价',
      minWidth: 120,
      fixed: 'right',
      formatter: 'formatAmount2',
@@ -429,7 +429,7 @@
    },
    {
      field: 'totalPrice',
      title: '预估金额',
      title: '预估含税金额',
      formatter: 'formatAmount2',
      minWidth: 120,
    },
src/views/erp/purchase/request/detail/index.vue
@@ -42,13 +42,13 @@
    },
    {
      field: 'productPrice',
      title: '单价(参考价)',
      title: '含税单价(参考价)',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
    {
      field: 'totalPrice',
      title: '预估金额',
      title: '预估含税总价',
      minWidth: 120,
      formatter: ({ cellValue }) => erpPriceInputFormatter(cellValue) || '-',
    },
src/views/erp/purchase/request/index.vue
@@ -9,7 +9,7 @@
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import { message, Tabs, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -22,6 +22,7 @@
} from '#/api/erp/purchase/request';
import { $t } from '#/locales';
import DemandPanel from '../demand/modules/demand-panel.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
@@ -32,6 +33,16 @@
defineOptions({ name: 'ErpPurchaseRequest' });
const router = useRouter();
/**
 * é¡µç­¾ï¼š4.3 é‡‡è´­ç”³è¯·å•与 4.4 ç”³è¯·é‡‡è´­å•都是「提采购需求」,
 * åˆå¹¶ä¸ºä¸€ä¸ªèœå•,避免两个入口重复。
 */
const activeTab = ref('request');
const TAB_ITEMS = [
  { key: 'request', label: '采购申请单' },
  { key: 'demand', label: '申请采购单' },
];
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -188,6 +199,7 @@
    toolbarConfig: {
      refresh: true,
      search: true,
      custom: true,
    },
  } as VxeTableGridOptions<ErpPurchaseRequestApi.PurchaseRequest>,
  gridEvents: {
@@ -203,7 +215,11 @@
    <ImportModal @success="handleRefresh" />
    <OrderFormModal @success="handleRefresh" />
    <ProcessModal @success="handleRefresh" />
    <Grid table-title="采购申请列表">
    <div class="mx-4 mb-2">
      <Tabs v-model:active-key="activeTab" :items="TAB_ITEMS" />
    </div>
    <DemandPanel v-if="activeTab === 'demand'" />
    <Grid v-show="activeTab === 'request'" table-title="采购申请列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
src/views/erp/purchase/request/modules/item-form.vue
@@ -12,6 +12,7 @@
import { Input, InputNumber, DatePicker } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { calcTaxIncludedLine } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { useFormItemColumns } from '../data';
@@ -133,9 +134,9 @@
  } else {
    tableData.value[index] = row;
  }
  // è®¡ç®—预估金额
  // è®¡ç®—预估含税总价(单价为含税单价)
  if (row.productPrice && row.count) {
    row.totalPrice = erpPriceMultiply(row.productPrice, row.count) ?? 0;
    calcTaxIncludedLine(row);
  }
  emit('update:items', [...tableData.value]);
}
@@ -143,7 +144,7 @@
/** åˆå§‹åŒ–行数据 */
function initRow(row: ErpPurchaseRequestApi.PurchaseRequestItem) {
  if (row.productPrice && row.count) {
    row.totalPrice = erpPriceMultiply(row.productPrice, row.count) ?? 0;
    calcTaxIncludedLine(row);
  }
}
@@ -253,7 +254,9 @@
          <span class="font-medium text-foreground">合计:</span>
          <div class="flex space-x-4">
            <span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
            <span>预估金额:{{ erpPriceInputFormatter(summaries.totalPrice) }}</span>
            <span>
              é¢„估含税总价:{{ erpPriceInputFormatter(summaries.totalPrice) }}
            </span>
          </div>
        </div>
      </div>
src/views/erp/purchase/supplier-contract/data.ts
@@ -131,6 +131,18 @@
      formItemClass: 'col-span-2',
      componentProps: { placeholder: '请输入备注', rows: 3 },
    },
    {
      fieldName: 'blobIds',
      label: '合同附件',
      component: 'FileUpload',
      help: '支持上传合同扫描件、补充协议等',
      componentProps: {
        maxNumber: 10,
        multiple: true,
        valueKey: 'id',
      },
      formItemClass: 'col-span-2',
    },
  ];
}
src/views/erp/purchase/supplier-contract/index.vue
@@ -2,6 +2,9 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpSupplierContractApi } from '#/api/erp/purchase/supplier-contract';
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
@@ -17,6 +20,8 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const route = useRoute();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -76,6 +81,18 @@
    toolbarConfig: { refresh: true, search: true, custom: true },
  } as VxeTableGridOptions<ErpSupplierContractApi.Contract>,
});
/** ä»Žä¾›åº”商档案「合同」进入时,默认按该供应商筛选 */
onMounted(async () => {
  const supplierId = route.query.supplierId;
  if (!supplierId) {
    return;
  }
  await gridApi.formApi.setFieldValue('supplierId', Number(supplierId));
  const formValues = await gridApi.formApi.getValues();
  gridApi.formApi.setLatestSubmissionValues(formValues);
  await gridApi.reload(formValues);
});
</script>
<template>
  <Page auto-content-height>
src/views/erp/purchase/supplier-contract/modules/form.vue
@@ -10,6 +10,7 @@
import { useVbenForm } from '#/adapter/form';
import {
  createContract,
  getContract,
  updateContract,
} from '#/api/erp/purchase/supplier-contract';
import { $t } from '#/locales';
@@ -63,7 +64,17 @@
    isEdit.value = !!data?.id;
    await formApi.resetForm();
    if (data?.id) {
      await formApi.setValues(data);
      // åˆ—表行不带附件,编辑时重新拉取详情,回填合同附件
      const detail = (await getContract(data.id)) || data;
      await formApi.setValues(detail);
      const blobIds = (detail.attachmentList || []).map((item) => ({
        uid: String(item.id),
        name: item.name || '',
        url: item.url || '',
        status: 'done',
        id: item.id,
      }));
      await formApi.setFieldValue('blobIds', blobIds);
    }
  },
});
src/views/erp/sale/order/data.ts
@@ -285,14 +285,14 @@
    },
    {
      field: 'productPrice',
      title: '产品单价',
      title: '含税单价',
      minWidth: 120,
      fixed: 'right',
      slots: { default: 'productPrice' },
    },
    {
      field: 'totalProductPrice',
      title: '金额',
      title: '不含税金额',
      minWidth: 120,
      fixed: 'right',
      formatter: 'formatAmount2',
@@ -313,7 +313,7 @@
    },
    {
      field: 'totalPrice',
      title: '税额合计',
      title: '含税总价',
      minWidth: 120,
      fixed: 'right',
      formatter: 'formatAmount2',
src/views/erp/sale/order/modules/item-form.vue
@@ -12,6 +12,7 @@
import { Input, InputNumber, Select, Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { calcTaxIncludedLine } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
import { useFormItemColumns } from '../data';
@@ -170,7 +171,7 @@
  handleRowChange(row);
}
/** å¤„理行数据变更 */
/** å¤„理行数据变更:数量、含税单价、税率变化后立即重算金额 */
function handleRowChange(row: any) {
  const index = tableData.value.findIndex((item) => item.seq === row.seq);
  if (index === -1) {
@@ -178,16 +179,14 @@
  } else {
    tableData.value[index] = row;
  }
  calcTaxIncludedLine(row);
  emit('update:items', [...tableData.value]);
}
/** åˆå§‹åŒ–行数据 */
function initRow(row: ErpSaleOrderApi.SaleOrderItem) {
  if (row.productPrice && row.count) {
    row.totalProductPrice = erpPriceMultiply(row.productPrice, row.count) ?? 0;
    row.taxPrice =
      erpPriceMultiply(row.totalProductPrice, (row.taxPercent || 0) / 100) ?? 0;
    row.totalPrice = row.totalProductPrice + row.taxPrice;
    calcTaxIncludedLine(row);
  }
  // é»˜è®¤éœ€è¦ç”Ÿäº§
  if (row.needProduction === undefined) {
@@ -207,7 +206,7 @@
        throw new Error(`第 ${i + 1} è¡Œï¼šäº§å“æ•°é‡ä¸èƒ½ä¸ºç©º`);
      }
      if (!item.productPrice || item.productPrice <= 0) {
        throw new Error(`第 ${i + 1} è¡Œï¼šäº§å“å•价不能为空`);
        throw new Error(`第 ${i + 1} è¡Œï¼šå«ç¨Žå•价不能为空`);
      }
    }
  }
@@ -311,11 +310,11 @@
          <div class="flex space-x-4">
            <span>数量:{{ erpCountInputFormatter(summaries.count) }}</span>
            <span>
              é‡‘额:{{ erpPriceInputFormatter(summaries.totalProductPrice) }}
              ä¸å«ç¨Žé‡‘额:{{ erpPriceInputFormatter(summaries.totalProductPrice) }}
            </span>
            <span>税额:{{ erpPriceInputFormatter(summaries.taxPrice) }}</span>
            <span>
              ç¨Žé¢åˆè®¡ï¼š{{ erpPriceInputFormatter(summaries.totalPrice) }}
              å«ç¨Žæ€»ä»·ï¼š{{ erpPriceInputFormatter(summaries.totalPrice) }}
            </span>
          </div>
        </div>
src/views/erp/sale/order/modules/print.vue
@@ -99,16 +99,21 @@
}
/**
 * æ˜Žç»†è¡Œï¼ˆåŽç«¯ä¸è¿”回明细行的 totalProductPrice/totalPrice,需按单价×数量计算:
 * é‡‘额 = å•ä»· Ã— æ•°é‡ï¼›ç¨Žé¢ = é‡‘额 Ã— ç¨ŽçŽ‡ï¼›å«ç¨Žé‡‘é¢ = é‡‘额 + ç¨Žé¢ï¼‰
 * æ˜Žç»†è¡Œï¼ˆå•价为含税单价):
 * å«ç¨Žæ€»ä»· = å•ä»· Ã— æ•°é‡ï¼›ç¨Žé¢ = å«ç¨Žæ€»ä»· Ã— ç¨Žçއ Ã·ï¼ˆ100 + ç¨ŽçŽ‡ï¼‰ï¼›ä¸å«ç¨Žé‡‘é¢ = å«ç¨Žæ€»ä»· âˆ’ ç¨Žé¢
 */
const printRows = computed(() =>
  (printData.value.items || []).map((item) => {
    const totalProductPrice =
      item.totalProductPrice ?? multiplyPrice(item.productPrice, item.count) ?? 0;
    const totalPrice =
      item.totalPrice ?? multiplyPrice(item.productPrice, item.count) ?? 0;
    const taxPercent = item.taxPercent ?? 0;
    const taxPrice =
      item.taxPrice ?? multiplyPrice(totalProductPrice, (item.taxPercent ?? 0) / 100) ?? 0;
    const totalPrice = item.totalPrice ?? totalProductPrice + taxPrice;
      item.taxPrice ??
      (taxPercent > 0
        ? Number(((totalPrice * taxPercent) / (100 + taxPercent)).toFixed(2))
        : 0);
    const totalProductPrice =
      item.totalProductPrice ?? Number((totalPrice - taxPrice).toFixed(2));
    return { ...item, totalProductPrice, taxPrice, totalPrice };
  }),
);
@@ -262,12 +267,12 @@
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              äº§å“å•ä»·(元)
              å«ç¨Žå•ä»·(元)
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"
            >
              é‡‘额(元)
              ä¸å«ç¨Žé‡‘额(元)
            </th>
            <th
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right font-bold"