liu
3 天以前 fb8ebb92202e59b902d3976e75ddae7ad2e60cb8
feat(erp): 销售订单列表支持打印/导出详情单据,供企业盖章留存

- 每行操作新增「打印」按钮,加载订单详情渲染正式销售订单单据
- 单据含订单头信息、产品明细(金额/税额/含税金额自动计算)、优惠与金额合计、客户/公司盖章栏、打印人/时间
- 复用 WMS 出库单打印模式,浏览器打印或另存为 PDF

Co-Authored-By: Claude <noreply@anthropic.com>
已添加1个文件
已修改2个文件
481 ■■■■■ 文件已修改
src/api/erp/sale/order/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/print.vue 459 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/sale/order/index.ts
@@ -10,6 +10,7 @@
    customerId: number; // å®¢æˆ·ç¼–号
    customerName?: string; // å®¢æˆ·åç§°
    accountId?: number; // æ”¶æ¬¾è´¦æˆ·ç¼–号
    saleUserId?: number; // é”€å”®äººå‘˜ç¼–号
    orderTime: Date; // è®¢å•æ—¶é—´
    totalCount: number; // åˆè®¡æ•°é‡
    totalPrice: number; // åˆè®¡é‡‘额,单位:元
@@ -31,6 +32,9 @@
    productionFinishTime?: Date; // ç”Ÿäº§å®Œæˆæ—¶é—´
    hasSalesNotice?: boolean; // æ˜¯å¦å·²ç”Ÿæˆå‘货通知单
    canCreateSalesNotice?: boolean; // æ˜¯å¦å¯ä»¥ç”Ÿæˆå‘货通知单
    creator?: string; // åˆ›å»ºäººç¼–号(字符串)
    creatorName?: string; // åˆ›å»ºäººåç§°
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    items?: SaleOrderItem[]; // é”€å”®è®¢å•产品明细列表
  }
@@ -53,6 +57,7 @@
    totalTaxPrice?: number; // å«ç¨Žæ€»ä»·ï¼Œå•位:元
    remark?: string; // å¤‡æ³¨
    stockCount?: number; // åº“存数量(显示字段)
    productSpecification?: string; // è§„格型号(显示字段)
    needProduction?: number; // æ˜¯å¦éœ€è¦ç”Ÿäº§ï¼š0-不需要,1-需要
    mpsId?: number; // MPS编号
    batchCode?: string; // æ‰¹æ¬¡å·
src/views/erp/sale/order/index.vue
@@ -24,6 +24,7 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import SaleOrderPrint from './modules/print.vue';
import SalesNoticeForm from '#/views/wls/salesnotice/modules/form.vue';
/** ERP é”€å”®è®¢å•列表 */
@@ -140,6 +141,12 @@
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** æ‰“印/导出销售订单详情,供企业盖章留存 */
const printRef = ref<InstanceType<typeof SaleOrderPrint>>();
function handlePrint(row: ErpSaleOrderApi.SaleOrder) {
  printRef.value?.print(row.id!);
}
/** æŸ¥çœ‹å…³è”合同 */
function handleViewContract(row: ErpSaleOrderApi.SaleOrder) {
  if (row.contractId) {
@@ -220,6 +227,7 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <SalesNoticeFormModal />
    <SaleOrderPrint ref="printRef" />
    <Grid table-title="销售订单列表">
      <template #toolbar-tools>
        <TableAction
@@ -304,6 +312,13 @@
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '打印',
              type: 'link',
              icon: 'lucide:printer',
              auth: ['erp:sale-order:query'],
              onClick: handlePrint.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
@@ -360,7 +375,7 @@
              type: 'link',
              icon: 'ant-design:file-protect-outlined',
              auth: ['mes:wm-sales-notice:create'],
              ifShow: () => row.status === 20 && row.canCreateSalesNotice,
              ifShow: () => row.status === 20 && !!row.canCreateSalesNotice,
              onClick: handleGenerateNotice.bind(null, row),
            },
            {
src/views/erp/sale/order/modules/print.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,459 @@
<script lang="ts" setup>
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import { computed, nextTick, ref } from 'vue';
import { Barcode, BarcodeFormatEnum } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { erpPriceMultiply, formatDateTime } from '@vben/utils';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getAccountSimpleList } from '#/api/erp/finance/account';
import { getSaleOrder } from '#/api/erp/sale/order';
import { getSimpleUserList } from '#/api/system/user';
/**
 * é”€å”®è®¢å•打印组件
 *
 * ç”¨äºŽå¯¼å‡º/打印销售订单详细信息,供企业盖章留存。
 * æ‰“印样式参考 WMS å‡ºåº“单打印(wms/order/shipment/modules/print.vue)。
 */
defineOptions({ name: 'ErpSaleOrderPrint' });
const userStore = useUserStore();
const printData = ref<Partial<ErpSaleOrderApi.SaleOrder>>({});
/** é”€å”®äººå‘˜æ˜µç§°æ˜ å°„ */
const saleUserMap = ref<Map<number, string>>(new Map());
/** ç»“算账户名称映射 */
const accountMap = ref<Map<number, string>>(new Map());
/** å®¢æˆ·åç§°æ˜ å°„(详情接口不返回客户名称,需按客户编号解析) */
const customerMap = ref<Map<number, string>>(new Map());
/** æ‰“印时间 */
const printTime = ref('');
/** è®¢å•状态文案(status ä¸ºç©ºæ—¶ä¸è°ƒç”¨ getDictLabel,避免内部对 undefined æ‰§è¡Œ toString æŠ¥é”™ï¼‰ */
const statusText = computed(() => {
  if (printData.value.status == null) {
    return '-';
  }
  const label = getDictLabel(DICT_TYPE.ERP_AUDIT_STATUS, printData.value.status);
  return label || '-';
});
/** å‡ºåº“状态文案 */
const outStatusText = computed(() => {
  switch (printData.value.outStatus) {
    case 0:
      return '未出库';
    case 1:
      return '已预留';
    case 2:
      return '部分出库';
    case 3:
      return '全部出库';
    default:
      return '-';
  }
});
/** ç”Ÿäº§çŠ¶æ€æ–‡æ¡ˆ */
const productionStatusText = computed(() => {
  if (printData.value.needProduction !== 1) {
    return '无需生产';
  }
  return printData.value.productionStatus === 1 ? '已完成' : '未完成';
});
/** é”€å”®äººå‘˜åç§° */
const saleUserName = computed(
  () => saleUserMap.value.get(printData.value.saleUserId!) || '-',
);
/** ç»“算账户名称 */
const accountName = computed(
  () => accountMap.value.get(printData.value.accountId!) || '-',
);
/** å®¢æˆ·åç§°ï¼šä¼˜å…ˆå–详情返回的客户名称,否则按客户编号解析 */
const customerName = computed(
  () =>
    printData.value.customerName ||
    customerMap.value.get(printData.value.customerId!) ||
    '-',
);
/** åˆ›å»ºäººåç§°ï¼šè¯¦æƒ…接口不返回创建人名称,按创建人编号解析 */
const creatorName = computed(
  () =>
    printData.value.creatorName ||
    saleUserMap.value.get(Number(printData.value.creator)) ||
    '-',
);
/** å•ä»· Ã— æ•°é‡ï¼ˆä»»ä¸€ä¸ºç©ºè¿”回 undefined) */
function multiplyPrice(price?: number, count?: number) {
  return price == null || count == null ? undefined : erpPriceMultiply(price, count);
}
/**
 * æ˜Žç»†è¡Œï¼ˆåŽç«¯ä¸è¿”回明细行的 totalProductPrice/totalPrice,需按单价×数量计算:
 * é‡‘额 = å•ä»· Ã— æ•°é‡ï¼›ç¨Žé¢ = é‡‘额 Ã— ç¨ŽçŽ‡ï¼›å«ç¨Žé‡‘é¢ = é‡‘额 + ç¨Žé¢ï¼‰
 */
const printRows = computed(() =>
  (printData.value.items || []).map((item) => {
    const totalProductPrice =
      item.totalProductPrice ?? multiplyPrice(item.productPrice, item.count) ?? 0;
    const taxPrice =
      item.taxPrice ?? multiplyPrice(totalProductPrice, (item.taxPercent ?? 0) / 100) ?? 0;
    const totalPrice = item.totalPrice ?? totalProductPrice + taxPrice;
    return { ...item, totalProductPrice, taxPrice, totalPrice };
  }),
);
/** æ˜Žç»†åˆè®¡ï¼šæ•°é‡/金额优先取订单头(后端权威值),税额与含税金额按明细累加(订单头 totalPrice ä¸ºä¼˜æƒ åŽé‡‘额,另在金额信息区展示) */
const summaries = computed(() => {
  const rows = printRows.value;
  const count = rows.reduce((sum, item) => sum + (item.count || 0), 0);
  const totalProductPrice = rows.reduce(
    (sum, item) => sum + (item.totalProductPrice || 0),
    0,
  );
  const taxPrice = rows.reduce((sum, item) => sum + (item.taxPrice || 0), 0);
  const totalPrice = rows.reduce((sum, item) => sum + (item.totalPrice || 0), 0);
  return {
    count: printData.value.totalCount ?? count,
    totalProductPrice: printData.value.totalProductPrice ?? totalProductPrice,
    taxPrice,
    totalPrice,
  };
});
/** ä¿ç•™ä¸¤ä½å°æ•°ï¼ˆé‡‘额展示) */
function formatPrice(value?: number | null) {
  return value === undefined || value === null || Number.isNaN(Number(value))
    ? '-'
    : Number(value).toFixed(2);
}
/** ä¿ç•™ä¸‰ä½å°æ•°ï¼ˆæ•°é‡å±•示) */
function formatCount(value?: number | null) {
  return value === undefined || value === null || Number.isNaN(Number(value))
    ? '-'
    : Number(value).toFixed(3);
}
/** ç­‰å¾…条码和打印 DOM å®Œæˆç»˜åˆ¶ï¼Œé¿å…æµè§ˆå™¨æ‰“印到旧内容 */
function waitForPaint() {
  return new Promise<void>((resolve) => {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => resolve());
    });
  });
}
/** é€€å‡ºæ‰“印模式,恢复当前页面显示 */
function removePrintMode() {
  document.body.classList.remove('erp-sale-order-printing');
}
/** æ‰“印销售订单:加载数据后只展示打印区域,再调用浏览器打印 */
async function print(id: number) {
  const [order, userList, accountList, customerList] = await Promise.all([
    getSaleOrder(id),
    getSimpleUserList(),
    getAccountSimpleList(),
    getCustomerSimpleList(),
  ]);
  printData.value = order;
  saleUserMap.value = new Map(
    userList.map((item) => [Number(item.id), item.nickname || item.username]),
  );
  accountMap.value = new Map(
    accountList.map((item) => [Number(item.id), item.name]),
  );
  customerMap.value = new Map(
    customerList.map((item) => [Number(item.id), item.name]),
  );
  printTime.value = formatDateTime(new Date());
  await nextTick();
  await waitForPaint();
  document.body.classList.add('erp-sale-order-printing');
  window.addEventListener('afterprint', removePrintMode, { once: true });
  window.print();
}
defineExpose({ print });
</script>
<template>
  <Teleport to="body">
    <div
      id="erpSaleOrderPrint"
      class="erp-sale-order-print pointer-events-none fixed left-0 top-0 z-[-1] w-full bg-white text-[#303133] opacity-0"
    >
      <div class="relative mb-2">
        <h2 class="m-0 text-center text-[1.6em] font-bold leading-[1.2]">
          é”€å”®è®¢å•
        </h2>
        <div v-if="printData.no" class="absolute right-0 top-0">
          <Barcode
            :content="printData.no"
            :display-value="false"
            :format="BarcodeFormatEnum.CODE39"
            :height="40"
            :width="180"
          />
        </div>
      </div>
      <!-- åŸºæœ¬ä¿¡æ¯ -->
      <div
        class="mb-3 grid grid-cols-3 gap-x-6 gap-y-2 border border-solid border-[#dcdfe6] p-3 text-sm leading-[1.6]"
      >
        <div>订单单号:{{ printData.no || '-' }}</div>
        <div>
          è®¢å•时间:{{
            formatDateTime(printData.orderTime) || '-'
          }}
        </div>
        <div>客户:{{ customerName }}</div>
        <div>销售人员:{{ saleUserName }}</div>
        <div>关联合同:{{ printData.contractNo || '-' }}</div>
        <div>结算账户:{{ accountName }}</div>
        <div>订单状态:{{ statusText }}</div>
        <div>出库状态:{{ outStatusText }}</div>
        <div>生产状态:{{ productionStatusText }}</div>
        <div>创建人:{{ creatorName }}</div>
        <div>
          åˆ›å»ºæ—¶é—´ï¼š{{
            formatDateTime(printData.createTime) || '-'
          }}
        </div>
        <div class="col-span-3">备注:{{ printData.remark || '-' }}</div>
      </div>
      <!-- äº§å“æ˜Žç»† -->
      <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-center 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"
            >
              äº§å“å•ä»·(元)
            </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"
            >
              ç¨Žé¢(元)
            </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-left font-bold"
            >
              å¤‡æ³¨
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, index) in printRows" :key="item.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">
              <div>{{ item.productName || '-' }}</div>
              <div v-if="item.productSpecification" class="text-xs">
                {{ item.productSpecification }}
              </div>
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-center">
              {{ item.productUnitName || '-' }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatCount(item.count) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.productPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.totalProductPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.taxPercent) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.taxPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2 text-right">
              {{ formatPrice(item.totalPrice) }}
            </td>
            <td class="border border-solid border-[#dcdfe6] p-2">
              {{ item.remark || '-' }}
            </td>
          </tr>
          <tr v-if="printData.items && printData.items.length > 0">
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-center"
            >
              åˆè®¡
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatCount(summaries.count) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatPrice(summaries.totalProductPrice) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatPrice(summaries.taxPrice) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2 text-right"
            >
              {{ formatPrice(summaries.totalPrice) }}
            </td>
            <td
              class="border border-solid border-[#dcdfe6] bg-[#f5f7fa] p-2"
            ></td>
          </tr>
          <tr v-if="printRows.length === 0">
            <td
              class="border border-solid border-[#dcdfe6] p-2 text-center"
              colspan="10"
            >
              æš‚无明细
            </td>
          </tr>
        </tbody>
      </table>
      <!-- é‡‘额信息 -->
      <div
        class="mt-3 grid grid-cols-4 gap-x-6 gap-y-2 border border-solid border-[#dcdfe6] p-3 text-sm leading-[1.6]"
      >
        <div>优惠率(%):{{ formatPrice(printData.discountPercent) }}</div>
        <div>付款优惠(元):{{ formatPrice(printData.discountPrice) }}</div>
        <div>优惠后金额(元):{{ formatPrice(printData.totalPrice) }}</div>
        <div>收取订金(元):{{ formatPrice(printData.depositPrice) }}</div>
      </div>
      <!-- ç›–章栏 -->
      <div
        class="mt-6 grid grid-cols-2 gap-x-10 gap-y-4 border border-dashed border-[#dcdfe6] p-4 text-sm leading-[1.8]"
      >
        <div>
          å®¢æˆ·ç›–ç« /签字:<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
        <div>
          å…¬å¸ç›–ç« /签字:<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
        <div>
          æ—¥æœŸï¼š<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
        <div>
          æ—¥æœŸï¼š<span class="inline-block w-36 border-b border-solid border-[#303133]"></span>
        </div>
      </div>
      <!-- æ‰“印信息 -->
      <div
        class="mt-4 flex justify-between text-xs text-[#909399]"
      >
        <div>
          æ‰“印人员:{{ userStore.userInfo?.nickname || '-' }}
        </div>
        <div>打印时间:{{ printTime || '-' }}</div>
      </div>
    </div>
  </Teleport>
</template>
<style scoped>
@page {
  margin: 8mm 10mm;
}
@media print {
  :global(body.erp-sale-order-printing) {
    padding: 0 !important;
    margin: 0 !important;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  :global(body.erp-sale-order-printing *) {
    visibility: hidden !important;
  }
  :global(body.erp-sale-order-printing .erp-sale-order-print),
  :global(body.erp-sale-order-printing .erp-sale-order-print *) {
    visibility: visible !important;
  }
  :global(body.erp-sale-order-printing .erp-sale-order-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>