gaoluyang
5 天以前 9fb4410c37213041cb56ee9cdc531db85cf4bb7b
feat(erp): 采购来票采购订单改为多选并支持分摊金额

- 新增订单分摊控件:多选已审核、未开票金额大于 0 的采购订单,按未开票金额自动分摊,可手工调整
- 一张来票只能关联同一供应商的采购订单,选择多个供应商时拦截并提示
- 来票金额变化时自动重新分摊;分摊合计必须等于来票金额
- 编辑来票时把本张来票已分摊的金额退回订单(选中订单的未开票金额按其他来票计算),剩余未开票金额保留在订单上
- 来票列表展示关联订单号(orderNos),详情展示分摊明细
- 移除原单选采购订单组件

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加1个文件
已删除1个文件
已修改4个文件
737 ■■■■■ 文件已修改
src/api/erp/purchase/invoice/index.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/data.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/form.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/order-allocation-field.vue 435 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/purchase-order-select.vue 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/invoice/index.ts
@@ -14,6 +14,21 @@
    status?: number;
  }
  /** é‡‡è´­è®¢å•分摊行 */
  export interface PurchaseOrderAllocation {
    purchaseOrderId: number;
    orderNo?: string;
    supplierId?: number;
    supplierName?: string;
    orderTotalPrice?: number;
    /** è¯¥è®¢å•其他来票已占用金额 */
    invoicedPrice?: number;
    /** è¯¥è®¢å•未开票金额(不含本张来票) */
    remainPrice?: number;
    /** æœ¬å¼ æ¥ç¥¨æœ¬æ¬¡åˆ†æ‘Šåˆ°è¯¥è®¢å•的金额 */
    price: number;
  }
  /** æ¥ç¥¨ä¿¡æ¯ */
  export interface PurchaseInvoice {
    id?: number;
@@ -22,6 +37,11 @@
    supplierName?: string;
    purchaseOrderId?: number;
    order?: PurchaseOrder;
    /** å…³è”采购订单号(多张以 ã€ æ‹¼æŽ¥ï¼‰ */
    orderNos?: string;
    orderCount?: number;
    /** é‡‡è´­è®¢å•分摊明细(多选订单、分摊金额) */
    orderAllocations?: PurchaseOrderAllocation[];
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
src/views/erp/purchase/invoice/data.ts
@@ -8,14 +8,19 @@
import { markRaw } from 'vue';
import PurchaseOrderSelect from './modules/purchase-order-select.vue';
import OrderAllocationField from './modules/order-allocation-field.vue';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
function round2(value: number) {
  return Math.round((value + Number.EPSILON) * 100) / 100;
}
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(
  orderRemainInvoicePrice?: Ref<number | undefined>,
  formType?: Ref<FormType>,
): VbenFormSchema[] {
  return [
    {
@@ -41,27 +46,35 @@
      },
    },
    {
      fieldName: 'purchaseOrderId',
      fieldName: 'orderAllocations',
      label: '采购订单',
      component: markRaw(PurchaseOrderSelect),
      rules: 'required',
      component: markRaw(OrderAllocationField),
      formItemClass: 'md:col-span-2',
      dependencies: {
        triggerFields: ['id'],
        disabled: (values) => !!values.id,
        triggerFields: ['id', 'price'],
        componentProps: (values, form) => ({
          invoicePrice: values.price,
          // ç¼–辑时把本张来票已分摊的金额退回订单,重新选择订单时未开票金额按其他来票计算
          invoiceId: values.id ? Number(values.id) : undefined,
          disabled: formType?.value === 'detail',
          // åˆ†æ‘Šè¡Œå˜åŒ–:带出供应商名称(同一供应商)与未开票金额合计
          onChange: (rows: Array<Record<string, any>>) => {
            const supplierNames = [
              ...new Set(
                rows
                  .map((row) => row.supplierName)
                  .filter((name): name is string => !!name),
              ),
            ];
            form.setFieldValue('supplierName', supplierNames.join('、'));
            if (orderRemainInvoicePrice) {
              orderRemainInvoicePrice.value = round2(
                rows.reduce((sum, row) => sum + Number(row.remainPrice ?? 0), 0),
              );
            }
          },
        }),
      },
      componentProps: (_values, form) => ({
        placeholder: '请选择采购订单',
        onChange: (
          order:
            | { supplierName?: string; remainInvoicePrice?: number }
            | undefined,
        ) => {
          form.setFieldValue('supplierName', order?.supplierName);
          if (orderRemainInvoicePrice) {
            orderRemainInvoicePrice.value = order?.remainInvoicePrice;
          }
        },
      }),
    },
    {
      fieldName: 'supplierName',
@@ -97,7 +110,7 @@
      rules: 'required',
      description: () =>
        orderRemainInvoicePrice?.value != null
          ? `采购订单未开票金额:${Number(orderRemainInvoicePrice.value).toFixed(2)} å…ƒ`
          ? `所选采购订单未开票金额合计:${Number(orderRemainInvoicePrice.value).toFixed(2)} å…ƒ`
          : '',
      componentProps: {
        class: '!w-full',
@@ -106,7 +119,7 @@
        precision: 2,
      },
      dependencies: {
        triggerFields: ['purchaseOrderId'],
        triggerFields: ['orderAllocations'],
        componentProps: () => ({
          class: '!w-full',
          placeholder: '请输入来票金额',
@@ -211,8 +224,8 @@
    },
    {
      title: '采购订单编号',
      field: 'order',
      minWidth: 160,
      field: 'orderNos',
      minWidth: 220,
      slots: { default: 'orderNo' },
    },
    {
src/views/erp/purchase/invoice/index.vue
@@ -152,7 +152,7 @@
        </Button>
      </template>
      <template #orderNo="{ row }">
        <span>{{ row.order?.no ?? '--' }}</span>
        <span>{{ row.orderNos || row.order?.no || '--' }}</span>
      </template>
      <template #attachment="{ row }">
        <Tag v-if="!row.hasAttachment" color="warning">未上传</Tag>
src/views/erp/purchase/invoice/modules/form.vue
@@ -45,7 +45,7 @@
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(orderRemainInvoicePrice),
  schema: useFormSchema(orderRemainInvoicePrice, formType),
  showDefaultActions: false,
});
@@ -57,6 +57,24 @@
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as ErpPurchaseInvoiceApi.PurchaseInvoice;
    // æ ¡éªŒé‡‡è´­è®¢å•分摊:至少一张订单,且分摊合计 = æ¥ç¥¨é‡‘额
    const allocations = data.orderAllocations ?? [];
    if (allocations.length === 0) {
      modalApi.unlock();
      message.error('请至少选择一张采购订单,并把来票金额分摊到订单上');
      return;
    }
    const allocationTotal = allocations.reduce(
      (sum, row) => sum + Number(row.price ?? 0),
      0,
    );
    if (Math.abs(allocationTotal - Number(data.price ?? 0)) > 0.001) {
      modalApi.unlock();
      message.error(
        `各采购订单分摊合计 ${allocationTotal.toFixed(2)} å…ƒä¸Žæ¥ç¥¨é‡‘额 ${Number(data.price ?? 0).toFixed(2)} å…ƒä¸ä¸€è‡´ï¼Œè¯·ç‚¹å‡»ã€Œè‡ªåŠ¨åˆ†æ‘Šã€æˆ–è°ƒæ•´åˆ†æ‘Šé‡‘é¢`,
      );
      return;
    }
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
@@ -85,7 +103,7 @@
    }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(orderRemainInvoicePrice));
    formApi.updateSchema(useFormSchema(orderRemainInvoicePrice, formType));
    await formApi.resetForm();
    // OCR è¯†åˆ«æ•°æ®é¢„填(新建场景,编辑/详情不处理)
    const ocr = data?.ocrData;
@@ -117,6 +135,11 @@
          }));
      }
      await formApi.setValues(formData.value as any);
      // å·²ä¿å­˜å•据:用分摊明细回填「未开票金额合计」,保证来票金额提示与上限正确
      orderRemainInvoicePrice.value = (formData.value?.orderAllocations ?? []).reduce(
        (sum, row) => sum + Number(row.remainPrice ?? 0),
        0,
      );
    } finally {
      modalApi.unlock();
    }
src/views/erp/purchase/invoice/modules/order-allocation-field.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,435 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseOrderApi } from '#/api/erp/purchase/order';
import { computed, nextTick, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, InputNumber, message, Modal, Table } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPurchaseOrderPage } from '#/api/erp/purchase/order';
import { getSupplierSimpleList } from '#/api/srm/supplier';
defineOptions({ name: 'ErpPurchaseInvoiceOrderAllocation' });
/** é‡‡è´­è®¢å•分摊行 */
export interface AllocationRow {
  purchaseOrderId: number;
  orderNo?: string;
  supplierId?: number;
  supplierName?: string;
  orderTotalPrice?: number;
  invoicedPrice?: number;
  remainPrice?: number;
  price: number;
}
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    /** æ¥ç¥¨ç¼–号:编辑时传入,选择订单时把本张来票已分摊的金额退回订单 */
    invoiceId?: number;
    /** æ¥ç¥¨é‡‘额:分摊合计需要等于它 */
    invoicePrice?: number;
    modelValue?: AllocationRow[];
  }>(),
  {
    disabled: false,
    invoiceId: undefined,
    invoicePrice: undefined,
    modelValue: () => [],
  },
);
const emit = defineEmits<{
  change: [rows: AllocationRow[]];
  'update:modelValue': [rows: AllocationRow[]];
}>();
const rows = computed<AllocationRow[]>(() => props.modelValue ?? []);
const allocationTotal = computed(() =>
  round2(rows.value.reduce((sum, row) => sum + (row.price ?? 0), 0)),
);
const diff = computed(() =>
  round2((props.invoicePrice ?? 0) - allocationTotal.value),
);
function round2(value: number) {
  return Math.round((value + Number.EPSILON) * 100) / 100;
}
function formatAmount(value?: number) {
  return value == null ? '--' : Number(value).toFixed(2);
}
function emitRows(next: AllocationRow[]) {
  emit('update:modelValue', next);
  emit('change', next);
}
/**
 * æŒ‰å‹¾é€‰é¡ºåºè‡ªåŠ¨åˆ†æ‘Šï¼šæ¯å¼ è®¢å•å…ˆå¡«æ»¡æœªå¼€ç¥¨é‡‘é¢ï¼Œå‰©ä¸‹çš„é¡ºå»¶ç»™ä¸‹ä¸€å¼ 
 */
function distribute(list: AllocationRow[], total?: number) {
  let remaining = round2(Math.max(0, total ?? 0));
  return list.map((row) => {
    const cap = Math.max(0, round2(row.remainPrice ?? 0));
    const price = round2(Math.min(cap, remaining));
    remaining = round2(remaining - price);
    return { ...row, price };
  });
}
/** æ¥ç¥¨é‡‘额变化时重新自动分摊 */
function handleInvoicePriceChange(value?: number, oldValue?: number) {
  if (props.disabled || value === oldValue || rows.value.length === 0) {
    return;
  }
  emitRows(distribute(rows.value, value));
}
watch(
  () => props.invoicePrice,
  (value, oldValue) => handleInvoicePriceChange(value, oldValue),
);
/** è‡ªåŠ¨åˆ†æ‘Š */
function handleAutoAllocate() {
  if (rows.value.length === 0) {
    message.warning('请先选择采购订单');
    return;
  }
  emitRows(distribute(rows.value, props.invoicePrice));
}
/** ä¿®æ”¹æŸè¡Œåˆ†æ‘Šé‡‘额:差额顺延给其余订单(受各自未开票金额限制) */
function handleRowPriceChange(row: AllocationRow, value: null | number) {
  const cap = Math.max(0, round2(row.remainPrice ?? 0));
  let price = round2(Math.max(0, Number(value ?? 0)));
  if (price > cap) {
    price = cap;
    message.warning(
      `采购订单[${row.orderNo}]本次分摊不能超过未开票金额 ${formatAmount(cap)} å…ƒï¼Œå·²æŒ‰ä¸Šé™å¡«å…¥`,
    );
  }
  const next = rows.value.map((item) =>
    item.purchaseOrderId === row.purchaseOrderId
      ? { ...item, price }
      : { ...item },
  );
  // å·®é¢é¡ºå»¶åˆ°å…¶ä»–订单
  let gap = round2((props.invoicePrice ?? 0) - next.reduce((s, r) => s + r.price, 0));
  for (const item of next) {
    if (gap === 0) {
      break;
    }
    if (item.purchaseOrderId === row.purchaseOrderId) {
      continue;
    }
    const itemCap = Math.max(0, round2(item.remainPrice ?? 0));
    if (gap > 0) {
      const add = Math.min(itemCap - item.price, gap);
      if (add > 0) {
        item.price = round2(item.price + add);
        gap = round2(gap - add);
      }
    } else {
      const sub = Math.min(item.price, -gap);
      if (sub > 0) {
        item.price = round2(item.price - sub);
        gap = round2(gap + sub);
      }
    }
  }
  emitRows(next);
}
/** ç§»é™¤è®¢å•行 */
function handleRemove(row: AllocationRow) {
  emitRows(
    distribute(
      rows.value.filter(
        (item) => item.purchaseOrderId !== row.purchaseOrderId,
      ),
      props.invoicePrice,
    ),
  );
}
// ========== é‡‡è´­è®¢å•多选弹窗 ==========
const open = ref(false);
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'no',
        label: '订单单号',
        component: 'Input',
        componentProps: { placeholder: '请输入订单单号', allowClear: true },
      },
      {
        fieldName: 'supplierId',
        label: '供应商',
        component: 'ApiSelect',
        componentProps: {
          placeholder: '请选择供应商',
          allowClear: true,
          showSearch: true,
          api: getSupplierSimpleList,
          labelField: 'name',
          valueField: 'id',
        },
      },
    ],
  },
  gridOptions: {
    columns: [
      { type: 'checkbox', width: 50, fixed: 'left' },
      { field: 'no', title: '订单单号', width: 200, fixed: 'left' },
      { field: 'supplierName', title: '供应商', minWidth: 140 },
      {
        field: 'orderTime',
        title: '订单时间',
        width: 120,
        formatter: 'formatDate',
      },
      {
        field: 'totalPrice',
        title: '含税金额',
        width: 120,
        formatter: 'formatAmount2',
      },
      {
        field: 'remainInvoicePrice',
        title: '未开票金额',
        width: 120,
        formatter: 'formatAmount2',
      },
    ],
    height: 620,
    keepSource: true,
    checkboxConfig: {
      highlight: true,
      reserve: true,
    },
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getPurchaseOrderPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            status: 20, // ä»…展示已审核通过的采购订单
            remainPriceEnable: true, // ä»…展示未开票金额大于 0 çš„订单
            ...formValues,
            // ç¼–辑来票时排除本张来票自身的分摊,把已分摊金额退回订单
            invoiceId: props.invoiceId,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<ErpPurchaseOrderApi.PurchaseOrder>,
});
function getTableRows() {
  return gridApi.grid.getTableData().fullData as ErpPurchaseOrderApi.PurchaseOrder[];
}
function getCheckedRows() {
  const recordMap = new Map<number, ErpPurchaseOrderApi.PurchaseOrder>();
  const records = [
    ...(gridApi.grid.getCheckboxReserveRecords?.() ?? []),
    ...(gridApi.grid.getCheckboxRecords?.() ?? []),
  ] as ErpPurchaseOrderApi.PurchaseOrder[];
  records.forEach((row) => {
    if (row.id) {
      recordMap.set(row.id, row);
    }
  });
  return [...recordMap.values()];
}
async function applyPreSelection() {
  const selectedIds = new Set(rows.value.map((row) => row.purchaseOrderId));
  if (selectedIds.size === 0) {
    return;
  }
  for (const row of getTableRows()) {
    if (row.id && selectedIds.has(row.id)) {
      await gridApi.grid.setCheckboxRow(row, true);
    }
  }
}
async function handleOpenPicker() {
  if (props.disabled) {
    return;
  }
  open.value = true;
  await nextTick();
  await gridApi.grid.clearCheckboxReserve();
  await gridApi.query();
  await nextTick();
  await applyPreSelection();
}
/** ç¡®è®¤é€‰æ‹©ï¼šä¿ç•™å·²é€‰è®¢å•的显示信息,订单顺序按本次选择顺序 */
async function handlePickerConfirm() {
  const selected = getCheckedRows();
  if (selected.length === 0) {
    message.warning('请至少选择一张采购订单');
    return;
  }
  // ä¸€å¼ æ¥ç¥¨åªèƒ½å…³è”同一供应商的采购订单
  const supplierMap = new Map<
    number,
    { name: string; orderNos: string[] }
  >();
  selected.forEach((order) => {
    if (order.supplierId == null) {
      return;
    }
    const item = supplierMap.get(order.supplierId) ?? {
      name: order.supplierName ?? `ID=${order.supplierId}`,
      orderNos: [],
    };
    item.orderNos.push(order.no ?? String(order.id));
    supplierMap.set(order.supplierId, item);
  });
  if (supplierMap.size > 1) {
    const detail = [...supplierMap.values()]
      .map((item) => `${item.name}(${item.orderNos.join('、')})`)
      .join('、');
    message.error(
      `所选采购订单包含多个供应商:${detail}。一张来票只能关联同一供应商的采购订单,请只勾选同一供应商的订单后重试`,
    );
    return;
  }
  const existed = new Map(
    rows.value.map((row) => [row.purchaseOrderId, row] as const),
  );
  const next = selected.map<AllocationRow>((order) => {
    const old = existed.get(order.id!);
    const remainPrice = round2(order.remainInvoicePrice ?? 0);
    return {
      purchaseOrderId: order.id!,
      orderNo: order.no,
      supplierId: order.supplierId,
      supplierName: order.supplierName,
      orderTotalPrice: order.totalPrice,
      remainPrice,
      invoicedPrice: round2((order.totalPrice ?? 0) - remainPrice),
      price: old?.price ?? 0,
    };
  });
  emitRows(distribute(next, props.invoicePrice));
  open.value = false;
}
const columns = [
  { title: '采购订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
  { title: '供应商', dataIndex: 'supplierName', key: 'supplierName' },
  { title: '订单金额', dataIndex: 'orderTotalPrice', key: 'orderTotalPrice', width: 130 },
  { title: '未开票金额', dataIndex: 'remainPrice', key: 'remainPrice', width: 130 },
  { title: '本次分摊金额', dataIndex: 'price', key: 'price', width: 160 },
  { title: '操作', key: 'action', width: 80 },
];
</script>
<template>
  <div class="w-full">
    <div v-if="!disabled" class="mb-2 flex items-center gap-2">
      <Button type="primary" ghost @click="handleOpenPicker">
        <IconifyIcon class="mr-1 size-4" icon="lucide:search" />
        é€‰æ‹©é‡‡è´­è®¢å•
      </Button>
      <Button :disabled="rows.length === 0" @click="handleAutoAllocate">
        è‡ªåŠ¨åˆ†æ‘Š
      </Button>
      <span class="text-xs text-gray-400">
        ä¸€å¼ æ¥ç¥¨å¯å…³è”同一供应商的多张采购订单;默认按未开票金额自动分摊,可手工调整
      </span>
    </div>
    <Table
      :columns="columns as any"
      :data-source="rows"
      :pagination="false"
      bordered
      row-key="purchaseOrderId"
      size="small"
    >
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'orderTotalPrice'">
          {{ formatAmount(record.orderTotalPrice) }}
        </template>
        <template v-else-if="column.key === 'remainPrice'">
          {{ formatAmount(record.remainPrice) }}
        </template>
        <template v-else-if="column.key === 'price'">
          <InputNumber
            :disabled="disabled"
            :max="record.remainPrice ?? undefined"
            :min="0"
            :precision="2"
            :value="record.price"
            class="!w-full"
            size="small"
            @change="
              (value: any) =>
                handleRowPriceChange(record as unknown as AllocationRow, value)
            "
          />
        </template>
        <template v-else-if="column.key === 'action'">
          <Button
            v-if="!disabled"
            danger
            size="small"
            type="link"
            @click="handleRemove(record as unknown as AllocationRow)"
          >
            ç§»é™¤
          </Button>
        </template>
      </template>
      <template #emptyText>
        <span class="text-gray-400">暂未选择采购订单</span>
      </template>
    </Table>
    <div
      v-if="rows.length > 0"
      class="mt-2 flex flex-wrap items-center justify-end gap-4 text-sm"
    >
      <span>
        åˆ†æ‘Šåˆè®¡ï¼š<span class="font-medium">{{ formatAmount(allocationTotal) }}</span> å…ƒ
      </span>
      <span>
        æ¥ç¥¨é‡‘额:<span class="font-medium">{{ formatAmount(invoicePrice) }}</span> å…ƒ
      </span>
      <span :class="diff === 0 ? 'text-green-600' : 'text-red-500'">
        å¾…分摊差额:<span class="font-medium">{{ formatAmount(diff) }}</span> å…ƒ
      </span>
    </div>
    <Modal
      v-model:open="open"
      title="选择采购订单(仅已审核通过、未开票金额大于 0)"
      width="85%"
      :style="{ top: '4vh' }"
      @ok="handlePickerConfirm"
    >
      <Grid table-title="采购订单列表" />
    </Modal>
  </div>
</template>
src/views/erp/purchase/invoice/modules/purchase-order-select.vue
ÎļþÒÑɾ³ý