| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |