| | |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { useAccess } from '@vben/access'; |
| | | import { AftersaleReturnStatusEnum } from '@vben/constants'; |
| | | |
| | | import { Button, Card, Descriptions, message, Popconfirm, Select, Tag } from 'ant-design-vue'; |
| | | import { Button, Card, Descriptions, Input, InputNumber, message, Modal, Popconfirm, Select, Tag } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm } from '#/adapter/form'; |
| | | import { approveReturn, createReturn, getReturn } from '#/api/aftersales/return'; |
| | | import { approveReturn, cancelReturn, completeRefund, createReturn, getReturn } from '#/api/aftersales/return'; |
| | | import { getTicket } from '#/api/aftersales/ticket'; |
| | | import { AftersalesTicketSelect } from '#/views/aftersales/ticket/components'; |
| | | import { getSaleOrder } from '#/api/erp/sale/order'; |
| | | import { getSaleOrderItemListByOrderId } from '#/api/erp/sale/order'; |
| | | |
| | | defineOptions({ name: 'AftersalesReturnDetail' }); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const { hasAccessByCodes } = useAccess(); |
| | | const loading = ref(false); |
| | | const submitting = ref(false); |
| | | const returnData = ref<AftersalesReturnApi.Return>(); |
| | | const ticketData = ref<AftersalesTicketApi.Ticket>(); |
| | | const saleOrderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]); |
| | | const requestedQuantities = ref<Record<number, number>>({}); |
| | | |
| | | function initializeRequestedQuantities() { |
| | | requestedQuantities.value = Object.fromEntries( |
| | | (ticketData.value?.items || []).filter((item) => item.id).map((item) => [item.id!, item.returnQuantity || 0]), |
| | | ); |
| | | } |
| | | |
| | | const returnId = computed(() => route.params.id as string); |
| | | const ticketIdFromQuery = computed(() => Number(route.query.ticketId || 0)); |
| | | const isCreate = computed(() => returnId.value === 'new'); |
| | | |
| | | const isDraft = computed(() => returnData.value?.status === AftersaleReturnStatusEnum.DRAFT); |
| | | const canConfirmRefund = computed(() => returnData.value?.status === AftersaleReturnStatusEnum.INBOUND |
| | | || (returnData.value?.returnType === 3 && returnData.value?.status === AftersaleReturnStatusEnum.SYNCED_MES)); |
| | | const canCancelReturn = computed(() => returnData.value?.status === AftersaleReturnStatusEnum.DRAFT |
| | | || returnData.value?.status === AftersaleReturnStatusEnum.SYNCED_MES |
| | | || returnData.value?.status === AftersaleReturnStatusEnum.QC_CHECKING |
| | | || returnData.value?.status === AftersaleReturnStatusEnum.QC_DONE); |
| | | const refundModalOpen = ref(false); |
| | | const refundSubmitting = ref(false); |
| | | const actualRefundPrice = ref<number>(0); |
| | | const refundVoucherNo = ref(''); |
| | | |
| | | function openRefundModal() { |
| | | actualRefundPrice.value = returnData.value?.returnType === 2 ? 0 : (returnData.value?.totalReturnPrice || 0); |
| | | refundVoucherNo.value = ''; |
| | | refundModalOpen.value = true; |
| | | } |
| | | |
| | | async function handleConfirmRefund() { |
| | | if (!returnData.value?.id || !refundVoucherNo.value.trim()) { |
| | | message.warning('请填写退款流水号或换货单号'); |
| | | return; |
| | | } |
| | | refundSubmitting.value = true; |
| | | try { |
| | | await completeRefund({ |
| | | id: returnData.value.id, |
| | | actualRefundPrice: actualRefundPrice.value || 0, |
| | | voucherNo: refundVoucherNo.value.trim(), |
| | | }); |
| | | refundModalOpen.value = false; |
| | | message.success('退款/换货完成,工单已完结'); |
| | | await loadReturn(); |
| | | } finally { |
| | | refundSubmitting.value = false; |
| | | } |
| | | } |
| | | |
| | | const statusMap: Record<number, { color: string; label: string }> = { |
| | | [AftersaleReturnStatusEnum.DRAFT]: { color: 'default', label: '草稿' }, |
| | |
| | | loading.value = true; |
| | | try { |
| | | ticketData.value = await getTicket(ticketIdFromQuery.value); |
| | | initializeRequestedQuantities(); |
| | | if (ticketData.value?.saleOrderId) { |
| | | const order = await getSaleOrder(ticketData.value.saleOrderId); |
| | | saleOrderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || []; |
| | | saleOrderItems.value = await getSaleOrderItemListByOrderId(ticketData.value.saleOrderId); |
| | | } |
| | | } finally { |
| | | loading.value = false; |
| | |
| | | loading.value = true; |
| | | try { |
| | | ticketData.value = await getTicket(item.id as number); |
| | | initializeRequestedQuantities(); |
| | | if (ticketData.value?.saleOrderId) { |
| | | const order = await getSaleOrder(ticketData.value.saleOrderId); |
| | | saleOrderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || []; |
| | | saleOrderItems.value = await getSaleOrderItemListByOrderId(ticketData.value.saleOrderId); |
| | | } |
| | | } finally { |
| | | loading.value = false; |
| | |
| | | const needReturnItems = computed(() => |
| | | (ticketData.value?.items || []).filter((item) => item.needReturn), |
| | | ); |
| | | |
| | | function findSaleOrderItem(item: AftersalesTicketApi.TicketItem) { |
| | | return saleOrderItems.value.find((orderItem) => orderItem.id === item.saleOrderItemId) |
| | | || saleOrderItems.value.find((orderItem) => orderItem.productId === item.itemId); |
| | | } |
| | | |
| | | async function handleCreateReturn() { |
| | | const { valid } = await createFormApi.validate(); |
| | |
| | | submitting.value = true; |
| | | try { |
| | | const formValues = await createFormApi.getValues(); |
| | | const sourceItems = formValues.returnType === 3 ? (ticketData.value?.items || []) : needReturnItems.value; |
| | | const selectedItems = sourceItems.filter((item) => (requestedQuantities.value[item.id!] || 0) > 0); |
| | | if (!selectedItems.length) { |
| | | message.warning('请为本次申请填写至少一项正数数量;普通退货只能选择工单标记为需退货的物料'); |
| | | return; |
| | | } |
| | | if (selectedItems.some((item) => (requestedQuantities.value[item.id!] || 0) > (item.saleQuantity || 0))) { |
| | | message.warning('申请数量不能超过工单销售数量'); |
| | | return; |
| | | } |
| | | if (selectedItems.some((item) => !findSaleOrderItem(item))) { |
| | | message.warning('销售订单明细与工单物料不匹配,请先核对工单关联的销售订单'); |
| | | return; |
| | | } |
| | | const newId = await createReturn({ |
| | | ticketId: ticketData.value!.id!, |
| | | customerId: ticketData.value!.customerId!, |
| | |
| | | returnReason: formValues.returnReason as string, |
| | | defectCategory: formValues.defectCategory as string | undefined, |
| | | remark: formValues.remark as string | undefined, |
| | | items: needReturnItems.value.map((item) => { |
| | | const orderItem = saleOrderItems.value.find((oi) => oi.productId === item.itemId); |
| | | items: selectedItems.map((item) => { |
| | | const orderItem = findSaleOrderItem(item); |
| | | const unitPrice = orderItem?.productPrice || 0; |
| | | const returnQuantity = item.returnQuantity || 0; |
| | | const returnQuantity = requestedQuantities.value[item.id!] || 0; |
| | | return { |
| | | ticketItemId: item.id, |
| | | itemId: item.itemId, |
| | |
| | | |
| | | async function handleApprove() { |
| | | await approveReturn(returnData.value!.id!); |
| | | message.success('审核通过,已同步MES创建退货单'); |
| | | message.success(returnData.value?.returnType === 3 ? '审核通过,等待退款确认' : '审核通过,已同步MES创建退货单'); |
| | | await loadReturn(); |
| | | } |
| | | |
| | | async function handleCancel() { |
| | | if (!returnData.value?.id) return; |
| | | await cancelReturn(returnData.value.id); |
| | | message.success('退货申请已取消,售后工单已恢复处理中'); |
| | | await loadReturn(); |
| | | } |
| | | |
| | |
| | | |
| | | <template> |
| | | <Page auto-content-height :loading="loading"> |
| | | <Modal v-model:open="refundModalOpen" title="确认外部退款/换货已完成" :confirm-loading="refundSubmitting" @ok="handleConfirmRefund"> |
| | | <div class="flex flex-col gap-3"> |
| | | <p>仅在财务退款到账或换货实际完成后确认。确认后工单将自动完结。</p> |
| | | <div>实际退款金额(换货可填 0)</div> |
| | | <InputNumber v-model:value="actualRefundPrice" :min="0" :max="returnData?.totalReturnPrice" class="w-full" /> |
| | | <div>退款流水号或换货单号</div> |
| | | <Input v-model:value="refundVoucherNo" :maxlength="100" placeholder="填写可追溯的凭证号" /> |
| | | </div> |
| | | </Modal> |
| | | <template #title> |
| | | <div class="flex items-center gap-3"> |
| | | <Button @click="goBack">返回</Button> |
| | |
| | | </Descriptions> |
| | | </Card> |
| | | |
| | | <Card v-if="needReturnItems.length" title="退货明细" class="mb-4"> |
| | | <Card v-if="ticketData.items?.length" title="工单物料明细" class="mb-4"> |
| | | <p class="mb-2 text-sm text-gray-500">填写本次申请数量。普通退货仅提交标记为“需退货”的物料;仅退款可选择任意工单物料。</p> |
| | | <div class="overflow-x-auto"> |
| | | <table class="w-full border text-sm"> |
| | | <thead class="bg-gray-50"> |
| | |
| | | <th class="border p-2 text-left">物料编码</th> |
| | | <th class="border p-2 text-left">物料名称</th> |
| | | <th class="border p-2 text-left">批次号</th> |
| | | <th class="border p-2 text-right">退货数量</th> |
| | | <th class="border p-2 text-center">需退货</th> |
| | | <th class="border p-2 text-right">本次申请数量</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tr v-for="item in needReturnItems" :key="item.id"> |
| | | <tr v-for="item in ticketData.items" :key="item.id"> |
| | | <td class="border p-2">{{ item.itemCode }}</td> |
| | | <td class="border p-2">{{ item.itemName }}</td> |
| | | <td class="border p-2">{{ item.batchCode || '-' }}</td> |
| | | <td class="border p-2 text-right">{{ item.returnQuantity || '-' }}</td> |
| | | <td class="border p-2 text-center">{{ item.needReturn ? '是' : '否' }}</td> |
| | | <td class="border p-2 text-right"> |
| | | <InputNumber v-model:value="requestedQuantities[item.id!]" :min="0" :max="item.returnQuantity && item.returnQuantity > 0 ? Math.min(item.returnQuantity, item.saleQuantity || 0) : (item.saleQuantity || 0)" class="w-28" /> |
| | | </td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | |
| | | <Descriptions.Item label="退货单号">{{ returnData.no }}</Descriptions.Item> |
| | | <Descriptions.Item label="状态"> |
| | | <Tag :color="statusMap[returnData.status!]?.color"> |
| | | {{ statusMap[returnData.status!]?.label }} |
| | | {{ returnData.returnType === 3 && returnData.status === AftersaleReturnStatusEnum.SYNCED_MES |
| | | ? '待退款确认' : statusMap[returnData.status!]?.label }} |
| | | </Tag> |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="客户">{{ returnData.customerName || '-' }}</Descriptions.Item> |
| | |
| | | <Descriptions.Item label="MES退货单号"> |
| | | {{ returnData.mesReturnCode || '-' }} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item v-if="returnData.status === AftersaleReturnStatusEnum.SYNCED_MES |
| | | || returnData.status === AftersaleReturnStatusEnum.QC_DONE |
| | | || returnData.status === AftersaleReturnStatusEnum.INBOUND" label="下一步" :span="3"> |
| | | {{ returnData.status === AftersaleReturnStatusEnum.INBOUND ? '等待外部退款或换货完成,再录入金额与凭证号' |
| | | : returnData.status === AftersaleReturnStatusEnum.QC_DONE ? '在 MES 销售退货单完成执行与上架' |
| | | : returnData.returnType === 3 ? '等待外部退款完成并录入凭证' : '在 MES 销售退货单中提交、检验、上架;入库后再确认退款或换货' }} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="退货原因" :span="3"> |
| | | {{ returnData.returnReason || '-' }} |
| | | </Descriptions.Item> |
| | |
| | | <Descriptions.Item label="实退金额"> |
| | | {{ returnData.actualRefundPrice ? `¥${returnData.actualRefundPrice}` : '-' }} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="退款/换货凭证">{{ returnData.refundVoucherNo || '-' }}</Descriptions.Item> |
| | | <Descriptions.Item label="质检结论">{{ returnData.qualityResult || '-' }}</Descriptions.Item> |
| | | <Descriptions.Item label="入库时间">{{ returnData.returnInboundTime || '-' }}</Descriptions.Item> |
| | | <Descriptions.Item label="退款时间">{{ returnData.refundTime || '-' }}</Descriptions.Item> |
| | |
| | | </template> |
| | | |
| | | <template #extra> |
| | | <div v-if="!isCreate && isDraft" class="flex gap-2"> |
| | | <div v-if="!isCreate" class="flex gap-2"> |
| | | <Popconfirm |
| | | v-if="isDraft && hasAccessByCodes(['aftersales:return:approve'])" |
| | | title="确认审核通过该退货申请?审核后将自动同步MES创建退货单。" |
| | | @confirm="handleApprove" |
| | | > |
| | | <Button type="primary">审核通过</Button> |
| | | </Popconfirm> |
| | | <Popconfirm |
| | | v-if="canCancelReturn && hasAccessByCodes(['aftersales:return:cancel'])" |
| | | title="确认取消退货申请?未入库的 MES 单据会同步取消,工单恢复处理中。" |
| | | @confirm="handleCancel" |
| | | > |
| | | <Button danger>取消申请</Button> |
| | | </Popconfirm> |
| | | <Button v-if="canConfirmRefund && hasAccessByCodes(['aftersales:return:refund'])" type="primary" @click="openRefundModal"> |
| | | 确认外部退款/换货完成 |
| | | </Button> |
| | | </div> |
| | | </template> |
| | | </Page> |