gaoluyang
5 天以前 b4128f0da8ecae56af47e805cf729a0e553f97a8
富边电子
1.迁移财务模块
已添加28个文件
已修改2个文件
11340 ■■■■■ 文件已修改
src/api/basicData/common.js 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/storageAttachment.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/financialManagement/accountSubject.js 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/financialManagement/fixedAsset.js 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/financialManagement/intangibleAsset.js 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/financialManagement/ledger.js 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/financialManagement/voucher.js 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/file/index.vue 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/image/index.vue 335 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Dialog/FileList.vue 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Dialog/FileListDialog.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Dialog/FormDialog.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/assets/fixedAssets.vue 482 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/assets/intangibleAssets.vue 480 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/generalLedger/index.vue 498 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/input-invoice.vue 945 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/payment.vue 299 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/paymentApply.vue 1016 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/purchaseIn.vue 212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/purchaseReturn.vue 198 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/payable/reconciliation.vue 766 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/invoiceApply.vue 902 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/outputInvoice.vue 608 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/receipt.vue 855 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/reconciliation.vue 738 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/salesOut.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/salesReturn.vue 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/detailLedger.vue 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/generalLedger.vue 312 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/index.vue 1186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/common.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
import request from '@/utils/request'
// é€šç”¨ä¸Šä¼ æŽ¥å£ï¼Œæ”¯æŒ FormData æ‰¹é‡ä¼ æ–‡ä»¶
export function uploadFile(data) {
  return request({
    url: '/common/upload',
    method: 'post',
    data,
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
}
// é€šç”¨ä¸Šä¼ æŽ¥å£ï¼Œæ”¯æŒ FormData æ‰¹é‡ä¼ æ–‡ä»¶,永不过期,慎用
export function uploadPublicFile(data) {
  return request({
    url: '/common/public/upload',
    method: 'post',
    data,
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
}
src/api/basicData/storageAttachment.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
// é™„件页面接口
import request from '@/utils/request'
// é™„件查询
export function attachmentList(query) {
    return request({
        url: '/storageAttachment/list',
        method: 'get',
        params: query
    })
}
// é™„件新增
export function createAttachment(data) {
    return request({
        url: '/storageAttachment/add',
        method: 'post',
        data
    })
}
// é™„件删除
export function deleteAttachment(data) {
    return request({
        url: '/storageAttachment/delete',
        method: 'delete',
        data
    })
}
src/api/financialManagement/accountSubject.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
import request from "@/utils/request";
// æŸ¥è¯¢æ€»å¸ç§‘目列表
export function listAccountSubject(query) {
  return request({
    url: "/accountSubject/list",
    method: "get",
    params: query,
  });
}
// æ–°å¢žæ€»å¸ç§‘ç›®
export function addAccountSubject(data) {
  return request({
    url: "/accountSubject/add",
    method: "post",
    data: data,
  });
}
// ä¿®æ”¹æ€»å¸ç§‘ç›®
export function updateAccountSubject(data) {
  return request({
    url: "/accountSubject/edit",
    method: "put",
    data: data,
  });
}
// åˆ é™¤æ€»å¸ç§‘ç›®
export function delAccountSubject(ids) {
  return request({
    url: "/accountSubject/remove/" + ids,
    method: "delete",
  });
}
// å¯¼å‡ºæ€»å¸ç§‘ç›®
export function exportAccountSubject(data) {
  return request({
    url: "/accountSubject/export",
    method: "post",
    data: data,
    responseType: "blob",
  });
}
src/api/financialManagement/fixedAsset.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
import request from "@/utils/request";
// å›ºå®šèµ„产分页查询(current/size)
export function listFixedAssetPage(params) {
  return request({
    url: "/financial/fixedAsset/page",
    method: "get",
    params,
  });
}
// æ–°å¢žå›ºå®šèµ„产
export function addFixedAsset(data) {
  return request({
    url: "/financial/fixedAsset/add",
    method: "post",
    data,
  });
}
// ä¿®æ”¹å›ºå®šèµ„产
export function updateFixedAsset(data) {
  return request({
    url: "/financial/fixedAsset/update",
    method: "put",
    data,
  });
}
// åˆ é™¤å›ºå®šèµ„产(后端要求 ids=1&ids=2 å½¢å¼ï¼‰
export function deleteFixedAsset(ids) {
  const idList = Array.isArray(ids) ? ids : [ids];
  const query = idList
    .filter(id => id !== undefined && id !== null && id !== "")
    .map(id => `ids=${encodeURIComponent(id)}`)
    .join("&");
  return request({
    url: `/financial/fixedAsset/delete?${query}`,
    method: "delete",
  });
}
// æŠ˜æ—§è®¡æï¼ˆ{} è¡¨ç¤ºå…¨éƒ¨åœ¨ç”¨èµ„产)
export function depreciateFixedAsset(data = {}) {
  return request({
    url: "/financial/fixedAsset/depreciate",
    method: "post",
    data,
  });
}
src/api/financialManagement/intangibleAsset.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
import request from "@/utils/request";
// æ— å½¢èµ„产分页查询(current/size)
export function listIntangibleAssetPage(params) {
  return request({
    url: "/financial/intangibleAsset/page",
    method: "get",
    params,
  });
}
// æ–°å¢žæ— å½¢èµ„产
export function addIntangibleAsset(data) {
  return request({
    url: "/financial/intangibleAsset/add",
    method: "post",
    data,
  });
}
// ä¿®æ”¹æ— å½¢èµ„产
export function updateIntangibleAsset(data) {
  return request({
    url: "/financial/intangibleAsset/update",
    method: "put",
    data,
  });
}
// åˆ é™¤æ— å½¢èµ„产(后端要求 ids=1&ids=2 å½¢å¼ï¼‰
export function deleteIntangibleAsset(ids) {
  const idList = Array.isArray(ids) ? ids : [ids];
  const query = idList
    .filter(id => id !== undefined && id !== null && id !== "")
    .map(id => `ids=${encodeURIComponent(id)}`)
    .join("&");
  return request({
    url: `/financial/intangibleAsset/delete?${query}`,
    method: "delete",
  });
}
// æ‘Šé”€è®¡æï¼ˆ{} è¡¨ç¤ºå…¨éƒ¨åœ¨ç”¨èµ„产)
export function amortizeIntangibleAsset(data = {}) {
  return request({
    url: "/financial/intangibleAsset/amortize",
    method: "post",
    data,
  });
}
src/api/financialManagement/ledger.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
import request from "@/utils/request";
// ç§‘目总账
export function getGeneralLedger(params) {
  return request({
    url: "/financial/ledger/general",
    method: "get",
    params,
  });
}
// ç§‘目明细账
export function getDetailLedger(params) {
  return request({
    url: "/financial/ledger/detail",
    method: "get",
    params,
  });
}
src/api/financialManagement/voucher.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
import request from "@/utils/request";
// å‡­è¯åˆ†é¡µæŸ¥è¯¢ï¼ˆcurrent/size + è¿‡æ»¤æ¡ä»¶ï¼‰
export function listVoucherPage(params) {
  return request({
    url: "/financial/voucher/page",
    method: "get",
    params,
  });
}
// æ–°å¢žå‡­è¯
export function addVoucher(data) {
  return request({
    url: "/financial/voucher/add",
    method: "post",
    data,
  });
}
// ä¿®æ”¹å‡­è¯ï¼ˆä»…未过账)
export function updateVoucher(data) {
  return request({
    url: "/financial/voucher/update",
    method: "put",
    data,
  });
}
// è¿‡è´¦
export function postVoucher(data) {
  return request({
    url: "/financial/voucher/post",
    method: "post",
    data,
  });
}
// ä½œåºŸ
export function cancelVoucher(data) {
  return request({
    url: "/financial/voucher/cancel",
    method: "post",
    data,
  });
}
// è¯¦æƒ…
export function getVoucherDetail(id) {
  return request({
    url: `/financial/voucher/detail/${id}`,
    method: "get",
  });
}
src/components/AttachmentUpload/file/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
<script setup>
import { UploadFilled } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/basicData/common'
const props = defineProps({
  fileList: {
    type: Array,
    default: () => [],
  },
  index: {
    type: Number,
    default: -1,
  },
  childrenKey: {
    type: String,
    default: 'files',
  },
  limit: {
    type: Number,
    default: 10,
  },
  fileSize: {
    type: Number,
    default: 50,
  },
  fileType: {
    type: Array,
    default: () => [],
  },
  buttonText: {
    type: String,
    default: '单击选择文件',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  uploadFieldName: {
    type: String,
    default: 'files',
  },
})
const emit = defineEmits(['update:fileList', 'change'])
const { proxy } = getCurrentInstance()
const uploadRef = ref()
const uploadQueueTimer = ref(null)
const uploading = ref(false)
const queuedUidSet = ref(new Set())
const innerList = ref([])
function readListFromProps() {
  if (props.index > -1) {
    const row = props.fileList?.[props.index]
    return Array.isArray(row?.[props.childrenKey]) ? row[props.childrenKey] : []
  }
  return Array.isArray(props.fileList) ? props.fileList : []
}
watch(
  () => props.fileList,
  () => {
    innerList.value = [...readListFromProps()]
  },
  { deep: true, immediate: true },
)
const currentList = computed({
  get() {
    return innerList.value
  },
  set(value) {
    const nextList = Array.isArray(value) ? value : []
    innerList.value = nextList
    if (props.index > -1) {
      const nextModelValue = Array.isArray(props.fileList) ? [...props.fileList] : []
      const currentRow = nextModelValue[props.index] || {}
      nextModelValue[props.index] = {
        ...currentRow,
        [props.childrenKey]: nextList,
      }
      emit('update:fileList', nextModelValue)
      emit('change', nextList, nextModelValue)
      return
    }
    emit('update:fileList', nextList)
    emit('change', nextList, nextList)
  },
})
const displayFileList = computed(() => {
  return currentList.value.map((item, index) => ({
    uid: getItemUid(item, index),
    name: getItemName(item, index),
    url: getItemUrl(item),
    status: 'success',
    rawData: item,
  }))
})
const uploadTip = computed(() => {
  if (!props.fileType.length) return `单个文件不超过 ${props.fileSize}MB`
  return `支持 ${props.fileType.join('/')},单个文件不超过 ${props.fileSize}MB`
})
function getItemUid(item, index) {
  if (item?.id !== undefined && item?.id !== null) return `${item.id}`
  return `${getItemName(item, index)}-${getItemUrl(item) || index}`
}
function getItemUrl(item) {
  if (!item) return ''
  if (typeof item === 'string') return item
  return item.url || item.downloadURL || item.previewURL || item.previewUrl || ''
}
function getItemName(item, index = 0) {
  if (!item) return `file-${index + 1}`
  if (typeof item === 'string') return `file-${index + 1}`
  return item.name || item.originalFilename || item.fileName || item.uidFilename || `file-${index + 1}`
}
function normalizeResponseItem(item, index) {
  if (typeof item === 'string') {
    return {
      name: `file-${currentList.value.length + index + 1}`,
      url: item,
    }
  }
  return Object.assign({}, item, {
    url: item.url || item.downloadURL || item.previewURL || item.previewUrl || '',
    name: item.name || item.originalFilename || item.fileName || item.uidFilename || `file-${currentList.value.length + index + 1}`,
  })
}
function extractResponseArray(response) {
  if (Array.isArray(response)) return response
  if (Array.isArray(response?.data)) return response.data
  if (Array.isArray(response?.data?.data)) return response.data.data
  if (Array.isArray(response?.payload)) return response.payload
  if (Array.isArray(response?.payload?.data)) return response.payload.data
  if (Array.isArray(response?.rows)) return response.rows
  if (Array.isArray(response?.result)) return response.result
  return []
}
function validateFile(rawFile) {
  const extension = rawFile.name.includes('.')
    ? rawFile.name.slice(rawFile.name.lastIndexOf('.') + 1).toLowerCase()
    : ''
  if (props.fileType.length) {
    const isValidType = props.fileType.some((type) => {
      const normalizedType = String(type).toLowerCase()
      return rawFile.type.toLowerCase().includes(normalizedType) || extension === normalizedType
    })
    if (!isValidType) {
      proxy.$modal.msgError(`请上传 ${props.fileType.join('/')} æ ¼å¼çš„æ–‡ä»¶`)
      return false
    }
  }
  const isWithinSize = rawFile.size / 1024 / 1024 <= props.fileSize
  if (!isWithinSize) {
    proxy.$modal.msgError(`文件大小不能超过 ${props.fileSize}MB`)
    return false
  }
  return true
}
function scheduleUpload(uploadFiles) {
  clearTimeout(uploadQueueTimer.value)
  uploadQueueTimer.value = setTimeout(() => {
    const readyFiles = uploadFiles.filter((file) => file.raw && !queuedUidSet.value.has(file.uid))
    if (!readyFiles.length) return
    const remainCount = props.limit - currentList.value.length
    if (remainCount <= 0) {
      proxy.$modal.msgError(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶`)
      uploadRef.value?.clearFiles()
      return
    }
    const selectedFiles = readyFiles.slice(0, remainCount)
    if (selectedFiles.length < readyFiles.length) {
      proxy.$modal.msgWarning(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶ï¼Œè¶…出部分已忽略`)
    }
    selectedFiles.forEach((file) => queuedUidSet.value.add(file.uid))
    uploadSelectedFiles(selectedFiles)
  }, 0)
}
async function uploadSelectedFiles(files) {
  const validFiles = files.filter((file) => validateFile(file.raw))
  const invalidFiles = files.filter((file) => !validFiles.includes(file))
  invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
  if (!validFiles.length) {
    uploadRef.value?.clearFiles()
    return
  }
  const formData = new FormData()
  validFiles.forEach((file) => {
    formData.append(props.uploadFieldName, file.raw)
  })
  uploading.value = true
  proxy.$modal.loading('文件上传中,请稍候...')
  try {
    const response = await uploadFile(formData)
    const responseList = extractResponseArray(response).map((item, index) => normalizeResponseItem(item, index))
    if (!responseList.length) {
      proxy.$modal.msgError('上传接口未返回数组数据')
      return
    }
    currentList.value = [...currentList.value, ...responseList]
    proxy.$modal.msgSuccess('上传成功')
  } catch (error) {
    proxy.$modal.msgError(error?.message || '上传失败')
  } finally {
    validFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    uploadRef.value?.clearFiles()
    uploading.value = false
    proxy.$modal.closeLoading()
  }
}
function handleChange(file, uploadFiles) {
  if (props.disabled || uploading.value) return
  scheduleUpload(uploadFiles)
}
function handleRemove(file) {
  const targetUrl = file.url || getItemUrl(file.rawData)
  const nextList = currentList.value.filter((item, index) => {
    const itemUrl = getItemUrl(item)
    const itemName = getItemName(item, index)
    return !(itemUrl === targetUrl && itemName === file.name)
  })
  currentList.value = nextList
}
function handleExceed() {
  proxy.$modal.msgError(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶`)
}
function openFile(file) {
  const fileUrl = file.url || getItemUrl(file.rawData)
  if (!fileUrl) return
  window.open(fileUrl, '_blank')
}
onBeforeUnmount(() => {
  clearTimeout(uploadQueueTimer.value)
})
</script>
<template>
  <div class="attachment-upload-file">
    <el-upload
      ref="uploadRef"
      drag
      :auto-upload="false"
      :multiple="true"
      :show-file-list="true"
      :file-list="displayFileList"
      :disabled="disabled || uploading"
      :limit="limit"
      :on-change="handleChange"
      :on-remove="handleRemove"
      :on-exceed="handleExceed"
      :on-preview="openFile"
    >
      <el-icon class="upload-drag-icon"><UploadFilled /></el-icon>
      <div class="el-upload__text">
        å°†æ–‡ä»¶æ‹–到此处,或 <em>{{ buttonText }}</em>
      </div>
      <div class="upload-tip">{{ uploadTip }}</div>
    </el-upload>
  </div>
</template>
<style scoped lang="scss">
.attachment-upload-file {
  width: 100%;
}
.upload-drag-icon {
  font-size: 40px;
  color: var(--el-text-color-secondary);
}
.upload-tip {
  margin-top: 8px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
}
</style>
src/components/AttachmentUpload/image/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,335 @@
<script setup>
import {Plus} from '@element-plus/icons-vue'
import {uploadFile} from '@/api/basicData/common'
const props = defineProps({
  fileList: {
    type: Array,
    default: () => [],
  },
  index: {
    type: Number,
    default: -1,
  },
  childrenKey: {
    type: String,
    default: 'images',
  },
  limit: {
    type: Number,
    default: 10,
  },
  fileSize: {
    type: Number,
    default: 10,
  },
  fileType: {
    type: Array,
    default: () => ['png', 'jpg', 'jpeg', 'webp'],
  },
  buttonText: {
    type: String,
    default: '上传图片',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  uploadFieldName: {
    type: String,
    default: 'files',
  },
})
const emit = defineEmits(['update:fileList', 'change'])
const {proxy} = getCurrentInstance()
const uploadRef = ref()
const previewVisible = ref(false)
const previewUrl = ref('')
const uploadQueueTimer = ref(null)
const uploading = ref(false)
const queuedUidSet = ref(new Set())
const currentList = computed({
  get() {
    if (props.index > -1) {
      const row = props.fileList?.[props.index]
      return Array.isArray(row?.[props.childrenKey]) ? row[props.childrenKey] : []
    }
    return Array.isArray(props.fileList) ? props.fileList : []
  },
  set(value) {
    const nextList = Array.isArray(value) ? value : []
    if (props.index > -1) {
      const nextModelValue = Array.isArray(props.fileList) ? [...props.fileList] : []
      const currentRow = nextModelValue[props.index] || {}
      nextModelValue[props.index] = {
        ...currentRow,
        [props.childrenKey]: nextList,
      }
      emit('update:fileList', nextModelValue)
      emit('change', nextList, nextModelValue)
      return
    }
    emit('update:fileList', nextList)
    emit('change', nextList, nextList)
  },
})
const displayFileList = computed(() => {
  return currentList.value.map((item, index) => ({
    uid: getItemUid(item, index),
    name: getItemName(item, index),
    url: getItemUrl(item),
    status: 'success',
    rawData: item,
  }))
})
const uploadTip = computed(() => {
  return `支持 ${props.fileType.join('/')},单张不超过 ${props.fileSize}MB,最多上传 ${props.limit} å¼ å›¾ç‰‡`
})
function getItemUid(item, index) {
  if (item?.id !== undefined && item?.id !== null) return `${item.id}`
  return `${getItemName(item, index)}-${getItemUrl(item) || index}`
}
function getItemUrl(item) {
  if (!item) return ''
  if (typeof item === 'string') return item
  return item.url || item.previewURL || ''
}
function getItemName(item, index = 0) {
  if (!item) return `image-${index + 1}`
  if (typeof item === 'string') return `image-${index + 1}`
  return item.name || item.fileName || item.originalFilename || `image-${index + 1}`
}
function normalizeResponseItem(item, index) {
  if (typeof item === 'string') {
    return {
      name: `image-${currentList.value.length + index + 1}`,
      url: item,
    }
  }
  return Object.assign({}, item, {
    url: item.url || item.previewURL || item.previewUrl || '',
    name: item.name || item.originalFilename || item.fileName || `image-${currentList.value.length + index + 1}`,
  })
}
function extractResponseArray(response) {
  if (Array.isArray(response)) return response
  if (Array.isArray(response?.data)) return response.data
  if (Array.isArray(response?.data?.data)) return response.data.data
  if (Array.isArray(response?.payload)) return response.payload
  if (Array.isArray(response?.payload?.data)) return response.payload.data
  if (Array.isArray(response?.rows)) return response.rows
  if (Array.isArray(response?.result)) return response.result
  return []
}
function validateFile(rawFile) {
  let isValidType = false
  const extension = rawFile.name.includes('.')
      ? rawFile.name.slice(rawFile.name.lastIndexOf('.') + 1).toLowerCase()
      : ''
  if (props.fileType.length) {
    isValidType = props.fileType.some((type) => {
      const normalizedType = String(type).toLowerCase()
      return rawFile.type.toLowerCase().includes(normalizedType) || extension === normalizedType
    })
  } else {
    isValidType = rawFile.type.includes('image')
  }
  if (!isValidType) {
    proxy.$modal.msgError(`请上传 ${props.fileType.join('/')} æ ¼å¼çš„图片`)
    return false
  }
  const isWithinSize = rawFile.size / 1024 / 1024 <= props.fileSize
  if (!isWithinSize) {
    proxy.$modal.msgError(`图片大小不能超过 ${props.fileSize}MB`)
    return false
  }
  return true
}
function scheduleUpload(uploadFiles) {
  clearTimeout(uploadQueueTimer.value)
  uploadQueueTimer.value = setTimeout(() => {
    const readyFiles = uploadFiles.filter((file) => file.raw && !queuedUidSet.value.has(file.uid))
    if (!readyFiles.length) return
    const remainCount = props.limit - currentList.value.length
    if (remainCount <= 0) {
      proxy.$modal.msgError(`最多上传 ${props.limit} å¼ å›¾ç‰‡`)
      uploadRef.value?.clearFiles()
      return
    }
    const selectedFiles = readyFiles.slice(0, remainCount)
    if (selectedFiles.length < readyFiles.length) {
      proxy.$modal.msgWarning(`最多上传 ${props.limit} å¼ å›¾ç‰‡ï¼Œè¶…出部分已忽略`)
    }
    selectedFiles.forEach((file) => queuedUidSet.value.add(file.uid))
    uploadSelectedFiles(selectedFiles)
  }, 0)
}
async function uploadSelectedFiles(files) {
  const validFiles = files.filter((file) => validateFile(file.raw))
  const invalidFiles = files.filter((file) => !validFiles.includes(file))
  invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
  if (!validFiles.length) {
    uploadRef.value?.clearFiles()
    return
  }
  const formData = new FormData()
  validFiles.forEach((file) => {
    formData.append(props.uploadFieldName, file.raw)
  })
  uploading.value = true
  proxy.$modal.loading('图片上传中,请稍候...')
  try {
    const response = await uploadFile(formData)
    const responseList = extractResponseArray(response).map((item, index) => normalizeResponseItem(item, index))
    if (!responseList.length) {
      proxy.$modal.msgError('上传接口未返回数组数据')
      return
    }
    console.log('responseList', responseList)
    currentList.value = [...currentList.value, ...responseList]
    console.log('currentList.value', currentList.value)
    proxy.$modal.msgSuccess('上传成功')
  } catch (error) {
    proxy.$modal.msgError(error?.message || '上传失败')
  } finally {
    validFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    uploadRef.value?.clearFiles()
    uploading.value = false
    proxy.$modal.closeLoading()
  }
}
function handleChange(file, uploadFiles) {
  if (props.disabled || uploading.value) return
  scheduleUpload(uploadFiles)
}
function handleRemove(file) {
  const targetUrl = file.url || getItemUrl(file.rawData)
  const nextList = currentList.value.filter((item, index) => {
    const itemUrl = getItemUrl(item)
    const itemName = getItemName(item, index)
    return !(itemUrl === targetUrl && itemName === file.name)
  })
  currentList.value = nextList
}
function handlePreview(file) {
  previewUrl.value = file.url || getItemUrl(file.rawData)
  previewVisible.value = true
}
function handleExceed() {
  proxy.$modal.msgError(`最多上传 ${props.limit} å¼ å›¾ç‰‡`)
}
onBeforeUnmount(() => {
  clearTimeout(uploadQueueTimer.value)
})
</script>
<template>
  <div class="attachment-upload-image">
    <el-upload
        ref="uploadRef"
        :auto-upload="false"
        :multiple="true"
        :show-file-list="true"
        :file-list="displayFileList"
        list-type="picture-card"
        accept="image/*"
        :disabled="disabled || uploading"
        :limit="limit"
        :on-change="handleChange"
        :on-remove="handleRemove"
        :on-preview="handlePreview"
        :on-exceed="handleExceed"
    >
      <div class="upload-trigger">
        <el-icon>
          <Plus/>
        </el-icon>
        <span>{{ buttonText }}</span>
      </div>
    </el-upload>
    <div class="upload-tip">
      {{ uploadTip }}
    </div>
    <el-dialog v-model="previewVisible" title="图片预览" width="720px" append-to-body>
      <img class="preview-image" :src="previewUrl" alt="preview"/>
    </el-dialog>
  </div>
</template>
<style scoped lang="scss">
.attachment-upload-image {
  width: 100%;
}
.upload-trigger {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
  line-height: 1.2;
}
.upload-tip {
  margin-top: 8px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
}
.preview-image {
  display: block;
  max-width: 100%;
  margin: 0 auto;
}
:deep(.el-upload-list--picture-card) {
  margin: 0;
}
:deep(.el-upload--picture-card) {
  width: 132px;
  height: 132px;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
  width: 132px;
  height: 132px;
}
</style>
src/components/Dialog/FileList.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,263 @@
<template>
  <el-dialog v-model="isShow"
             :title="title"
             :width="width"
             @close="handleClose"
             class="attachment-dialog">
    <!-- å·¥å…·æ  -->
    <div v-if="editable"
         class="toolbar">
      <el-button type="primary"
                 size="small"
                 @click="handleUpload">
        ä¸Šä¼ é™„ä»¶
      </el-button>
    </div>
    <!-- ä¸Šä¼ ç»„件弹窗 -->
    <el-dialog v-model="uploadDialogVisible"
               title="上传附件"
               width="50%"
               @close="closeUpload">
      <AttachmentUpload v-model:file-list="newFileList" />
      <template #footer>
        <el-button @click="saveUpload">保存</el-button>
        <el-button @click="closeUpload">关闭</el-button>
      </template>
    </el-dialog>
    <!-- æ–‡ä»¶åˆ—表表格 -->
    <div class="table-container">
      <el-table :data="tableData"
                border
                class="attachment-table"
                :height="tableData.length > 0 ? 'auto' : '120px'">
        <el-table-column label="附件名称"
                         prop="originalFilename"
                         show-overflow-tooltip />
        <el-table-column v-if="showActions"
                         fixed="right"
                         label="操作"
                         :width="150"
                         align="center">
          <template #default="scope">
            <el-button link
                       type="primary"
                       size="small"
                       class="download-link"
                       @click="previewFile(scope.row.previewURL)">
              é¢„览
            </el-button>
            <el-button link
                       type="primary"
                       size="small"
                       class="download-link"
                       @click="downloadFile(scope.row.downloadURL)">
              ä¸‹è½½
            </el-button>
            <el-button v-if="editable"
                       link
                       type="danger"
                       size="small"
                       @click="handleDelete(scope.row)">
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </el-dialog>
  <filePreview ref="filePreviewRef" />
</template>
<script setup>
import { ElMessage } from 'element-plus'
  import { ref, computed, getCurrentInstance, onMounted, watch } from "vue";
  import AttachmentUpload from "@/components/AttachmentUpload/file/index.vue";
  import {
    attachmentList,
    deleteAttachment,
    createAttachment,
  } from "@/api/basicData/storageAttachment.js";
  import filePreview from '@/components/filePreview/index.vue'
  const filePreviewRef = ref()
  const props = defineProps({
    visible: {
      type: Boolean,
      required: true,
    },
    recordType: {
      type: String,
      default: "",
      required: true,
    },
    recordId: {
      type: Number,
      default: 0,
      required: true,
    },
    title: {
      type: String,
      default: "附件",
    },
    width: {
      type: String,
      default: "50%",
    },
    showActions: {
      type: Boolean,
      default: true,
    },
    editable: {
      type: Boolean,
      default: true,
    },
  });
  const emit = defineEmits(["close", "download", "upload", "delete"]);
  const { proxy } = getCurrentInstance();
  const tableData = ref([]);
  const uploadDialogVisible = ref(false);
  const newFileList = ref([]);
  const isShow = computed({
    get() {
      return props.visible;
    },
    set(val) {
      emit("update:visible", val);
    },
  });
  const handleClose = () => {
    isShow.value = false;
  };
  // é¢„览文件
  const previewFile = (url) => {
    if (url) {
      filePreviewRef.value.open(url)
    } else {
      ElMessage.warning('文件地址无效,无法预览')
    }
  }
  const handleUpload = () => {
    uploadDialogVisible.value = true;
  };
  const saveUpload = async () => {
    // æ£€æŸ¥æ˜¯å¦æœ‰æ–°ä¸Šä¼ çš„æ–‡ä»¶
    if (newFileList.value.length > 0) {
      createAttachment({
        application: "file",
        recordType: props.recordType,
        recordId: props.recordId,
        storageBlobDTOs: [...newFileList.value, ...tableData.value],
      }).then((res) => {
        if (res && res.code === 200) {
          proxy?.$modal?.msgSuccess("上传成功");
          newFileList.value = [];
          // åˆ·æ–°åˆ—表
          setList();
        }
      }).finally(() => {
        uploadDialogVisible.value = false;
      })
    }
  }
  const closeUpload = () => {
    newFileList.value = [];
    uploadDialogVisible.value = false;
  };
  const handleDelete = async (row, index) => {
    deleteAttachment([row.storageAttachmentId]).then((res) => {
      if (res && res.code === 200) {
        proxy?.$modal?.msgSuccess("删除成功");
        setList();
      }
    })
  };
  const setList = () => {
    attachmentList({
      recordType: props.recordType,
      recordId: props.recordId,
    }).then(res => {
      tableData.value = (res && res.data) || [];
    });
  };
  const downloadFile = url => {
    window.open(url, "_blank");
  };
  onMounted(() => {
    setList();
  });
</script>
<style scoped>
  .attachment-dialog {
    border-radius: 12px;
  }
  .toolbar {
    margin-bottom: 16px;
    text-align: right;
  }
  .table-container {
    max-height: 40vh;
    overflow-y: auto;
    min-height: 120px;
    padding-bottom: 16px;
    box-sizing: border-box;
    will-change: scroll-position;
    transform: translateZ(0);
    -webkit-overflow-scrolling: touch;
  }
  :deep(.el-table) {
    margin-bottom: 0;
  }
  :deep(.el-table__body-wrapper) {
    overflow-y: auto;
    will-change: transform;
    transform: translateZ(0);
  }
  :deep(.el-table__body tr) {
    transition: none;
  }
  :deep(.el-dialog__footer) {
    padding-top: 12px;
    border-top: 1px solid #e9ecef;
  }
  .attachment-table {
    border-radius: 8px;
  }
  :deep(.el-dialog__header) {
    background-color: #f8f9fa;
    border-bottom: 1px solid #e9ecef;
    padding: 16px 20px;
  }
  :deep(.el-dialog__title) {
    font-size: 16px;
    font-weight: 600;
  }
  :deep(.el-dialog__body) {
    padding: 16px 20px;
  }
  :deep(.el-table__empty-text) {
    color: #999;
  }
</style>
src/components/Dialog/FileListDialog.vue
@@ -77,6 +77,7 @@
                @pagination="paginationSearch"
                @change="handleChange" />
  </el-dialog>
<!-- // todo é™„件预览相关 -->
  <filePreview v-if="showPreview"
               ref="filePreviewRef" />
</template>
src/components/Dialog/FormDialog.vue
@@ -55,7 +55,7 @@
})
// è¯¦æƒ…模式不展示“确认”按钮,其它类型正常显示
const showConfirm = computed(() => props.operationType !== 'detail')
const showConfirm = computed(() => props.operationType !== 'detail' && props.operationType !== 'view')
const computedTitle = computed(() => {
  if (typeof props.title === 'function') {
src/views/financialManagement/assets/fixedAssets.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,482 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="资产编号:">
        <el-input v-model="filters.assetCode" placeholder="请输入资产编号" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="资产名称:">
        <el-input v-model="filters.assetName" placeholder="请输入资产名称" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="资产类别:">
        <el-select v-model="filters.category" placeholder="请选择类别" clearable style="width: 150px;">
          <el-option label="房屋建筑" value="building" />
          <el-option label="机器设备" value="machine" />
          <el-option label="运输工具" value="vehicle" />
          <el-option label="电子设备" value="electronic" />
          <el-option label="办公家具" value="furniture" />
        </el-select>
      </el-form-item>
      <el-form-item label="状态:">
        <el-select v-model="filters.status" placeholder="请选择状态" clearable style="width: 150px;">
          <el-option label="在用" value="in_use" />
          <el-option label="闲置" value="idle" />
          <el-option label="报废" value="scrapped" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="getTableData">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div>
          <el-statistic title="资产原值合计" :value="totalOriginalValue" precision="2" prefix="Â¥" />
          <el-statistic title="累计折旧合计" :value="totalDepreciation" precision="2" prefix="Â¥" style="margin-left: 30px;" />
          <el-statistic title="净值合计" :value="totalNetValue" precision="2" prefix="Â¥" style="margin-left: 30px;" />
        </div>
        <div>
          <el-button type="primary" @click="add" icon="Plus">新增资产</el-button>
          <el-button type="warning" @click="handleDepreciation" icon="Money">折旧计提</el-button>
          <!-- <el-button @click="handleOut" icon="Download">导出</el-button> -->
        </div>
      </div>
      <PIMTable
        rowKey="id"
        isSelection
        :column="columns"
        :tableData="dataList"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @selection-change="handleSelectionChange"
        @pagination="changePage"
      >
        <template #originalValue="{ row }">
          <span class="text-primary">Â¥{{ formatMoney(row.originalValue) }}</span>
        </template>
        <template #accumulatedDepreciation="{ row }">
          <span class="text-warning">Â¥{{ formatMoney(row.accumulatedDepreciation) }}</span>
        </template>
        <template #netValue="{ row }">
          <span class="text-success">Â¥{{ formatMoney(row.netValue) }}</span>
        </template>
        <template #category="{ row }">
          <el-tag>{{ getCategoryLabel(row.category) }}</el-tag>
        </template>
        <template #status="{ row }">
          <el-tag :type="getStatusType(row.status)">{{ getStatusLabel(row.status) }}</el-tag>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="primary" link @click="edit(row)">编辑</el-button>
          <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog :title="dialogTitle" v-model="dialogVisible" width="800px" @confirm="submitForm" @cancel="dialogVisible = false">
      <el-form :model="form" :rules="rules" :disabled="isView" ref="formRef" label-width="120px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="资产编号" prop="assetCode">
              <el-input v-model="form.assetCode" placeholder="系统自动生成" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="资产名称" prop="assetName">
              <el-input v-model="form.assetName" placeholder="请输入资产名称" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="资产类别" prop="category">
              <el-select v-model="form.category" placeholder="请选择资产类别" style="width: 100%;">
                <el-option label="房屋建筑" value="building" />
                <el-option label="机器设备" value="machine" />
                <el-option label="运输工具" value="vehicle" />
                <el-option label="电子设备" value="electronic" />
                <el-option label="办公家具" value="furniture" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="规格型号" prop="specification">
              <el-input v-model="form.specification" placeholder="请输入规格型号" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="购置日期" prop="purchaseDate">
              <el-date-picker v-model="form.purchaseDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="资产原值" prop="originalValue">
              <el-input-number v-model="form.originalValue" :min="0" :precision="2" style="width: 100%;" @change="calculateNetValue" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="使用年限" prop="usefulLife">
              <el-input-number v-model="form.usefulLife" :min="1" :max="50" style="width: 100%;" />
              <span style="margin-left: 10px;">å¹´</span>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="残值率" prop="residualRate">
              <el-input-number v-model="form.residualRate" :min="0" :max="10" :precision="2" style="width: 100%;" />
              <span style="margin-left: 10px;">%</span>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="累计折旧">
              <el-input v-model="form.accumulatedDepreciation" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="资产净值">
              <el-input v-model="form.netValue" disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="存放地点" prop="location">
              <el-input v-model="form.location" placeholder="请输入存放地点" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="使用部门" prop="department">
              <el-input v-model="form.department" placeholder="请输入使用部门" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="保管人" prop="keeper">
              <el-input v-model="form.keeper" placeholder="请输入保管人" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="状态" prop="status">
              <el-select v-model="form.status" placeholder="请选择状态" style="width: 100%;">
                <el-option label="在用" value="in_use" />
                <el-option label="闲置" value="idle" />
                <el-option label="报废" value="scrapped" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button v-if="!isView" type="primary" @click="submitForm">确定</el-button>
        <el-button @click="dialogVisible = false">取消</el-button>
      </template>
    </FormDialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import {
  listFixedAssetPage,
  addFixedAsset,
  updateFixedAsset,
  deleteFixedAsset,
  depreciateFixedAsset,
} from "@/api/financialManagement/fixedAsset";
defineOptions({
  name: "固定资产",
});
const filters = reactive({
  assetCode: "",
  assetName: "",
  category: "",
  status: "",
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "资产编号", prop: "assetCode", width: "130" },
  { label: "资产名称", prop: "assetName", width: "150" },
  { label: "资产类别", prop: "category", dataType: "slot", slot: "category" },
  { label: "规格型号", prop: "specification", width: "120" },
  { label: "资产原值", prop: "originalValue", dataType: "slot", slot: "originalValue" },
  { label: "累计折旧", prop: "accumulatedDepreciation", dataType: "slot", slot: "accumulatedDepreciation" },
  { label: "资产净值", prop: "netValue", dataType: "slot", slot: "netValue" },
  { label: "状态", prop: "status", dataType: "slot", slot: "status" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "180", fixed: "right" },
];
const dataList = ref([]);
const multipleList = ref([]);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const isEdit = ref(false);
const isView = ref(false);
const currentId = ref(null);
const selectedIds = computed(() =>
  multipleList.value
    .map(item => item?.id)
    .filter(id => id !== undefined && id !== null && id !== "")
);
const createDefaultForm = () => ({
  assetCode: "",
  assetName: "",
  category: "",
  specification: "",
  purchaseDate: "",
  originalValue: 0,
  usefulLife: 5,
  residualRate: 5,
  accumulatedDepreciation: 0,
  netValue: 0,
  location: "",
  department: "",
  keeper: "",
  status: "in_use",
  remark: "",
});
const form = reactive({
  ...createDefaultForm(),
});
const rules = {
  assetName: [{ required: true, message: "请输入资产名称", trigger: "blur" }],
  category: [{ required: true, message: "请选择资产类别", trigger: "change" }],
  purchaseDate: [{ required: true, message: "请选择购置日期", trigger: "change" }],
  originalValue: [{ required: true, message: "请输入资产原值", trigger: "blur" }],
  usefulLife: [{ required: true, message: "请输入使用年限", trigger: "blur" }],
};
const totalOriginalValue = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.originalValue), 0);
});
const totalDepreciation = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.accumulatedDepreciation), 0);
});
const totalNetValue = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.netValue), 0);
});
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const getCategoryLabel = (category) => {
  const map = {
    building: "房屋建筑",
    machine: "机器设备",
    vehicle: "运输工具",
    electronic: "电子设备",
    furniture: "办公家具",
  };
  return map[category] || category;
};
const getStatusLabel = (status) => {
  const key = String(status || "").toLowerCase();
  const map = { in_use: "在用", idle: "闲置", repair: "维修中", scrapped: "报废" };
  return map[key] || status;
};
const getStatusType = (status) => {
  const key = String(status || "").toLowerCase();
  const map = { in_use: "success", idle: "warning", repair: "warning", scrapped: "info" };
  return map[key] || "";
};
const calculateNetValue = () => {
  const originalValue = Number(form.originalValue || 0);
  const accumulatedDepreciation = Number(form.accumulatedDepreciation || 0);
  form.netValue = Number((originalValue - accumulatedDepreciation).toFixed(2));
};
// è”调约定:分页参数固定为 current/size,返回 data.records/data.total
const getTableData = async () => {
  try {
    const { data } = await listFixedAssetPage({
      current: pagination.currentPage,
      size: pagination.pageSize,
      assetCode: filters.assetCode,
      assetName: filters.assetName,
      category: filters.category,
      status: filters.status,
    });
    dataList.value = data?.records || [];
    multipleList.value = [];
    pagination.total = Number(data?.total || 0);
  } catch (error) {
    // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
  }
};
const handleSelectionChange = (selectionList) => {
  multipleList.value = selectionList;
};
const resetFilters = () => {
  filters.assetCode = "";
  filters.assetName = "";
  filters.category = "";
  filters.status = "";
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ current, size }) => {
  pagination.currentPage = current;
  pagination.pageSize = size;
  getTableData();
};
const buildAssetCode = () => `GD${Date.now().toString().slice(-10)}`;
const add = () => {
  isEdit.value = false;
  isView.value = false;
  currentId.value = null;
  dialogTitle.value = "新增固定资产";
  Object.assign(form, createDefaultForm(), {
    assetCode: buildAssetCode(),
    purchaseDate: new Date().toISOString().split('T')[0],
  });
  dialogVisible.value = true;
};
const edit = (row) => {
  isEdit.value = true;
  isView.value = false;
  currentId.value = row.id;
  dialogTitle.value = "编辑固定资产";
  Object.assign(form, createDefaultForm(), row);
  dialogVisible.value = true;
};
const view = (row) => {
  edit(row);
  isView.value = true;
};
const handleDelete = (row) => {
  ElMessageBox.confirm("确认删除该固定资产吗?", "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    // è”调约定:删除接口使用 ids=1&ids=2
    await deleteFixedAsset([row.id]);
    if (dataList.value.length === 1 && pagination.currentPage > 1) {
      pagination.currentPage -= 1;
    }
    ElMessage.success("删除成功");
    await getTableData();
  });
};
const handleDepreciation = () => {
  const ids = selectedIds.value;
  const confirmText = ids.length
    ? `确认对选中的 ${ids.length} æ¡èµ„产进行本月折旧计提吗?`
    : "确认进行本月折旧计提吗?";
  ElMessageBox.confirm(confirmText, "提示", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "info",
  }).then(async () => {
    await depreciateFixedAsset({ ids });
    ElMessage.success("折旧计提完成");
    await getTableData();
  });
};
const handleOut = () => {
  ElMessage.success("导出成功");
};
const submitForm = () => {
  if (isView.value) {
    dialogVisible.value = false;
    return;
  }
  formRef.value.validate(async valid => {
    if (valid) {
      try {
        calculateNetValue();
        const payload = { ...form };
        if (isEdit.value) {
          payload.id = currentId.value;
          await updateFixedAsset(payload);
          ElMessage.success("编辑成功");
        } else {
          await addFixedAsset(payload);
          ElMessage.success("新增成功");
        }
        dialogVisible.value = false;
        await getTableData();
      } catch (error) {
        // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
      }
    }
  });
};
onMounted(() => {
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  > div:first-child {
    display: flex;
    align-items: center;
  }
}
.text-primary {
  color: #409eff;
  font-weight: bold;
}
.text-warning {
  color: #e6a23c;
  font-weight: bold;
}
.text-success {
  color: #67c23a;
  font-weight: bold;
}
</style>
src/views/financialManagement/assets/intangibleAssets.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,480 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="资产编号:">
        <el-input v-model="filters.assetCode" placeholder="请输入资产编号" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="资产名称:">
        <el-input v-model="filters.assetName" placeholder="请输入资产名称" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="资产类别:">
        <el-select v-model="filters.category" placeholder="请选择类别" clearable style="width: 150px;">
          <el-option label="专利权" value="patent" />
          <el-option label="商标权" value="trademark" />
          <el-option label="著作权" value="copyright" />
          <el-option label="软件" value="software" />
          <el-option label="土地使用权" value="land" />
          <el-option label="其他" value="other" />
        </el-select>
      </el-form-item>
      <el-form-item label="状态:">
        <el-select v-model="filters.status" placeholder="请选择状态" clearable style="width: 150px;">
          <el-option label="在用" value="in_use" />
          <el-option label="闲置" value="idle" />
          <el-option label="已摊销完毕" value="amortized" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="getTableData">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div>
          <el-statistic title="资产原值合计" :value="totalOriginalValue" precision="2" prefix="Â¥" />
          <el-statistic title="累计摊销合计" :value="totalAmortization" precision="2" prefix="Â¥" style="margin-left: 30px;" />
          <el-statistic title="净值合计" :value="totalNetValue" precision="2" prefix="Â¥" style="margin-left: 30px;" />
        </div>
        <div>
          <el-button type="primary" @click="add" icon="Plus">新增资产</el-button>
          <el-button type="warning" @click="handleAmortization" icon="Money">摊销计提</el-button>
          <!-- <el-button @click="handleOut" icon="Download">导出</el-button> -->
        </div>
      </div>
      <PIMTable
        rowKey="id"
        isSelection
        :column="columns"
        :tableData="dataList"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @selection-change="handleSelectionChange"
        @pagination="changePage"
      >
        <template #originalValue="{ row }">
          <span class="text-primary">Â¥{{ formatMoney(row.originalValue) }}</span>
        </template>
        <template #accumulatedAmortization="{ row }">
          <span class="text-warning">Â¥{{ formatMoney(row.accumulatedAmortization) }}</span>
        </template>
        <template #netValue="{ row }">
          <span class="text-success">Â¥{{ formatMoney(row.netValue) }}</span>
        </template>
        <template #category="{ row }">
          <el-tag>{{ getCategoryLabel(row.category) }}</el-tag>
        </template>
        <template #status="{ row }">
          <el-tag :type="getStatusType(row.status)">{{ getStatusLabel(row.status) }}</el-tag>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="primary" link @click="edit(row)">编辑</el-button>
          <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog :title="dialogTitle" v-model="dialogVisible" width="800px" @confirm="submitForm" @cancel="dialogVisible = false">
      <el-form :model="form" :rules="rules" :disabled="isView" ref="formRef" label-width="120px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="资产编号" prop="assetCode">
              <el-input v-model="form.assetCode" placeholder="系统自动生成" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="资产名称" prop="assetName">
              <el-input v-model="form.assetName" placeholder="请输入资产名称" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="资产类别" prop="category">
              <el-select v-model="form.category" placeholder="请选择资产类别" style="width: 100%;">
                <el-option label="专利权" value="patent" />
                <el-option label="商标权" value="trademark" />
                <el-option label="著作权" value="copyright" />
                <el-option label="软件" value="software" />
                <el-option label="土地使用权" value="land" />
                <el-option label="其他" value="other" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="证书编号" prop="certificateNo">
              <el-input v-model="form.certificateNo" placeholder="请输入证书编号" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="取得日期" prop="acquisitionDate">
              <el-date-picker v-model="form.acquisitionDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="资产原值" prop="originalValue">
              <el-input-number v-model="form.originalValue" :min="0" :precision="2" style="width: 100%;" @change="calculateNetValue" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="摊销年限" prop="amortizationPeriod">
              <el-input-number v-model="form.amortizationPeriod" :min="1" :max="50" style="width: 100%;" />
              <span style="margin-left: 10px;">å¹´</span>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="残值率" prop="residualRate">
              <el-input-number v-model="form.residualRate" :min="0" :max="10" :precision="2" style="width: 100%;" />
              <span style="margin-left: 10px;">%</span>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="累计摊销">
              <el-input v-model="form.accumulatedAmortization" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="资产净值">
              <el-input v-model="form.netValue" disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="有效期至" prop="validityDate">
              <el-date-picker v-model="form.validityDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="状态" prop="status">
              <el-select v-model="form.status" placeholder="请选择状态" style="width: 100%;">
                <el-option label="在用" value="in_use" />
                <el-option label="闲置" value="idle" />
                <el-option label="已摊销完毕" value="amortized" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="资产描述" prop="description">
          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入资产描述" />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button v-if="!isView" type="primary" @click="submitForm">确定</el-button>
        <el-button @click="dialogVisible = false">取消</el-button>
      </template>
    </FormDialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import {
  listIntangibleAssetPage,
  addIntangibleAsset,
  updateIntangibleAsset,
  deleteIntangibleAsset,
  amortizeIntangibleAsset,
} from "@/api/financialManagement/intangibleAsset";
defineOptions({
  name: "无形资产",
});
const filters = reactive({
  assetCode: "",
  assetName: "",
  category: "",
  status: "",
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "资产编号", prop: "assetCode", width: "130" },
  { label: "资产名称", prop: "assetName", width: "150" },
  { label: "资产类别", prop: "category", dataType: "slot", slot: "category" },
  { label: "证书编号", prop: "certificateNo", width: "150" },
  { label: "资产原值", prop: "originalValue", dataType: "slot", slot: "originalValue" },
  { label: "累计摊销", prop: "accumulatedAmortization", dataType: "slot", slot: "accumulatedAmortization" },
  { label: "资产净值", prop: "netValue", dataType: "slot", slot: "netValue" },
  { label: "状态", prop: "status", dataType: "slot", slot: "status" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "180", fixed: "right" },
];
const dataList = ref([]);
const multipleList = ref([]);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const isEdit = ref(false);
const isView = ref(false);
const currentId = ref(null);
const selectedIds = computed(() =>
  multipleList.value
    .map(item => item?.id)
    .filter(id => id !== undefined && id !== null && id !== "")
);
const createDefaultForm = () => ({
  assetCode: "",
  assetName: "",
  category: "",
  certificateNo: "",
  acquisitionDate: "",
  originalValue: 0,
  amortizationPeriod: 10,
  residualRate: 0,
  accumulatedAmortization: 0,
  netValue: 0,
  validityDate: "",
  status: "in_use",
  description: "",
  remark: "",
});
const form = reactive({
  ...createDefaultForm(),
});
const rules = {
  assetName: [{ required: true, message: "请输入资产名称", trigger: "blur" }],
  category: [{ required: true, message: "请选择资产类别", trigger: "change" }],
  acquisitionDate: [{ required: true, message: "请选择取得日期", trigger: "change" }],
  originalValue: [{ required: true, message: "请输入资产原值", trigger: "blur" }],
  amortizationPeriod: [{ required: true, message: "请输入摊销年限", trigger: "blur" }],
};
const totalOriginalValue = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.originalValue), 0);
});
const totalAmortization = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.accumulatedAmortization), 0);
});
const totalNetValue = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.netValue), 0);
});
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const getCategoryLabel = (category) => {
  const map = {
    patent: "专利权",
    trademark: "商标权",
    copyright: "著作权",
    software: "软件",
    land: "土地使用权",
    other: "其他",
  };
  return map[category] || category;
};
const getStatusLabel = (status) => {
  const key = String(status || "").toLowerCase();
  const map = {
    in_use: "在用",
    idle: "闲置",
    expired: "已到期",
    amortized: "已摊销完毕",
  };
  return map[key] || status;
};
const getStatusType = (status) => {
  const key = String(status || "").toLowerCase();
  const map = { in_use: "success", idle: "warning", expired: "warning", amortized: "info" };
  return map[key] || "";
};
const calculateNetValue = () => {
  const originalValue = Number(form.originalValue || 0);
  const accumulatedAmortization = Number(form.accumulatedAmortization || 0);
  form.netValue = Number((originalValue - accumulatedAmortization).toFixed(2));
};
// è”调约定:分页参数固定为 current/size,返回 data.records/data.total
const getTableData = async () => {
  try {
    const { data } = await listIntangibleAssetPage({
      current: pagination.currentPage,
      size: pagination.pageSize,
      assetCode: filters.assetCode,
      assetName: filters.assetName,
      category: filters.category,
      status: filters.status,
    });
    dataList.value = data?.records || [];
    multipleList.value = [];
    pagination.total = Number(data?.total || 0);
  } catch (error) {
    // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
  }
};
const handleSelectionChange = (selectionList) => {
  multipleList.value = selectionList;
};
const resetFilters = () => {
  filters.assetCode = "";
  filters.assetName = "";
  filters.category = "";
  filters.status = "";
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ current, size }) => {
  pagination.currentPage = current;
  pagination.pageSize = size;
  getTableData();
};
const buildAssetCode = () => `WX${Date.now().toString().slice(-10)}`;
const add = () => {
  isEdit.value = false;
  isView.value = false;
  currentId.value = null;
  dialogTitle.value = "新增无形资产";
  Object.assign(form, createDefaultForm(), {
    assetCode: buildAssetCode(),
    acquisitionDate: new Date().toISOString().split('T')[0],
  });
  dialogVisible.value = true;
};
const edit = (row) => {
  isEdit.value = true;
  isView.value = false;
  currentId.value = row.id;
  dialogTitle.value = "编辑无形资产";
  Object.assign(form, createDefaultForm(), row);
  dialogVisible.value = true;
};
const view = (row) => {
  edit(row);
  isView.value = true;
};
const handleDelete = (row) => {
  ElMessageBox.confirm("确认删除该无形资产吗?", "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    // è”调约定:删除接口使用 ids=1&ids=2
    await deleteIntangibleAsset([row.id]);
    if (dataList.value.length === 1 && pagination.currentPage > 1) {
      pagination.currentPage -= 1;
    }
    ElMessage.success("删除成功");
    await getTableData();
  });
};
const handleAmortization = () => {
  const ids = selectedIds.value;
  const confirmText = ids.length
    ? `确认对选中的 ${ids.length} æ¡èµ„产进行本月摊销计提吗?`
    : "确认进行本月摊销计提吗?";
  ElMessageBox.confirm(confirmText, "提示", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "info",
  }).then(async () => {
    await amortizeIntangibleAsset({ ids });
    ElMessage.success("摊销计提完成");
    await getTableData();
  });
};
const handleOut = () => {
  ElMessage.success("导出成功");
};
const submitForm = () => {
  if (isView.value) {
    dialogVisible.value = false;
    return;
  }
  formRef.value.validate(async valid => {
    if (valid) {
      try {
        calculateNetValue();
        const payload = { ...form };
        if (isEdit.value) {
          payload.id = currentId.value;
          await updateIntangibleAsset(payload);
          ElMessage.success("编辑成功");
        } else {
          await addIntangibleAsset(payload);
          ElMessage.success("新增成功");
        }
        dialogVisible.value = false;
        await getTableData();
      } catch (error) {
        // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
      }
    }
  });
};
onMounted(() => {
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  > div:first-child {
    display: flex;
    align-items: center;
  }
}
.text-primary {
  color: #409eff;
  font-weight: bold;
}
.text-warning {
  color: #e6a23c;
  font-weight: bold;
}
.text-success {
  color: #67c23a;
  font-weight: bold;
}
</style>
src/views/financialManagement/generalLedger/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,498 @@
<template>
  <div class="app-container">
    <el-form :model="filters"
             :inline="true">
      <el-form-item label="科目编码:">
        <el-input v-model="filters.subjectCode"
                  placeholder="请输入科目编码"
                  clearable
                  style="width: 200px;" />
      </el-form-item>
      <el-form-item label="科目名称:">
        <el-input v-model="filters.subjectName"
                  placeholder="请输入科目名称"
                  clearable
                  style="width: 200px;" />
      </el-form-item>
      <el-form-item label="科目类型:">
        <el-select v-model="filters.subjectType"
                   placeholder="请选择"
                   clearable
                   style="width: 200px;">
          <el-option label="资产类"
                     value="资产类" />
          <el-option label="负债类"
                     value="负债类" />
          <el-option label="权益类"
                     value="权益类" />
          <el-option label="成本类"
                     value="成本类" />
          <el-option label="损益类"
                     value="损益类" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary"
                   @click="getTableData">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button type="primary"
                     @click="add"
                     icon="Plus">新增</el-button>
          <!-- <el-button @click="handleOut"
                     icon="Download">导出</el-button> -->
        </div>
      </div>
      <el-table ref="tableRef"
                v-loading="loading"
                :data="dataList"
                row-key="id"
                :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
                height="calc(100vh - 280px)"
                border
                stripe
                highlight-current-row
                class="subject-table">
        <el-table-column label="科目编码" prop="subjectCode" width="140">
          <template #default="scope">
            <span class="subject-code">{{ scope.row.subjectCode }}</span>
          </template>
        </el-table-column>
        <el-table-column label="科目名称" prop="subjectName" min-width="180">
          <template #default="scope">
            <span class="subject-name" :class="{ 'is-parent': scope.row.children?.length > 0 }">
              {{ scope.row.subjectName }}
            </span>
          </template>
        </el-table-column>
        <el-table-column label="科目类型" prop="subjectType" width="100" align="center">
          <template #default="scope">
            <el-tag size="small" :type="getSubjectTypeType(scope.row.subjectType)">
              {{ scope.row.subjectType }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="余额方向" prop="balanceDirection" width="100" align="center">
          <template #default="scope">
            <el-tag size="small" :type="scope.row.balanceDirection === '借方' ? 'primary' : 'danger'">
              {{ scope.row.balanceDirection }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="状态" prop="status" width="80" align="center">
          <template #default="scope">
            <el-tag size="small" :type="scope.row.status === 0 || scope.row.status === '0' ? 'success' : 'info'">
              {{ scope.row.status === 0 || scope.row.status === '0' ? '启用' : '禁用' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="备注" prop="remark" show-overflow-tooltip min-width="150" />
        <el-table-column label="操作" align="center" fixed="right" width="240">
          <template #default="scope">
            <el-button link type="primary" icon="Plus" @click="addChild(scope.row)">新增</el-button>
            <el-button link type="primary" icon="Edit" @click="edit(scope.row)">编辑</el-button>
            <el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
    <FormDialog :title="dialogTitle"
                v-model="dialogVisible"
                width="600px"
                @confirm="submitForm"
                @cancel="dialogVisible = false">
      <el-form :model="form"
               :rules="rules"
               ref="formRef"
               label-width="100px">
        <el-form-item label="父级科目">
          <el-input :model-value="parentSubjectLabel"
                    disabled />
        </el-form-item>
        <el-form-item label="科目编码"
                      prop="subjectCode">
          <el-input v-model="form.subjectCode"
                    placeholder="请输入科目编码" />
        </el-form-item>
        <el-form-item label="科目名称"
                      prop="subjectName">
          <el-input v-model="form.subjectName"
                    placeholder="请输入科目名称" />
        </el-form-item>
        <el-form-item label="科目类型"
                      prop="subjectType">
          <el-select v-model="form.subjectType"
                     placeholder="请选择科目类型"
                     style="width: 100%;">
            <el-option label="资产类"
                       value="资产类" />
            <el-option label="负债类"
                       value="负债类" />
            <el-option label="权益类"
                       value="权益类" />
            <el-option label="成本类"
                       value="成本类" />
            <el-option label="损益类"
                       value="损益类" />
          </el-select>
        </el-form-item>
        <el-form-item label="余额方向"
                      prop="balanceDirection">
          <el-radio-group v-model="form.balanceDirection">
            <el-radio label="借方">借方</el-radio>
            <el-radio label="贷方">贷方</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="状态"
                      prop="status">
          <el-radio-group v-model="form.status">
            <el-radio :label="0">启用</el-radio>
            <el-radio :label="1">禁用</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="备注"
                      prop="remark">
          <el-input v-model="form.remark"
                    type="textarea"
                    :rows="3"
                    placeholder="请输入备注" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="primary"
                   @click="submitForm">确定</el-button>
        <el-button @click="dialogVisible = false">取消</el-button>
      </template>
    </FormDialog>
  </div>
</template>
<script setup>
  import { ref, reactive, onMounted, getCurrentInstance, nextTick } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import FormDialog from "@/components/Dialog/FormDialog.vue";
  import {
    listAccountSubject,
    addAccountSubject,
    updateAccountSubject,
    delAccountSubject,
    exportAccountSubject,
  } from "@/api/financialManagement/accountSubject";
  defineOptions({
    name: "总帐科目",
  });
  const { proxy } = getCurrentInstance();
  const filters = reactive({
    subjectCode: "",
    subjectName: "",
    subjectType: "",
  });
  const pagination = reactive({
    currentPage: 1,
    pageSize: 10,
    total: 0,
  });
  const columns = [
    { label: "科目编码", prop: "subjectCode", width: "120" },
    { label: "科目名称", prop: "subjectName", width: "150" },
    { label: "科目类型", prop: "subjectType" },
    {
      label: "余额方向",
      prop: "balanceDirection",
      dataType: "tag",
      formatData: value => {
        if (value === "借方") {
          return "借方";
        }
        return "贷方";
      },
      formatType: value => {
        if (value === "借方") {
          return "primary";
        }
        return "danger";
      },
    },
    {
      label: "状态",
      prop: "status",
      dataType: "tag",
      formatData: value => {
        if (value === 0 || value === "0") {
          return "启用";
        }
        return "禁用";
      },
      formatType: value => {
        if (value === 0 || value === "0") {
          return "success";
        }
        return "info";
      },
    },
    { label: "备注", prop: "remark", showOverflowTooltip: true },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: "220",
      operation: [
        {
          name: "新增",
          type: "primary",
          clickFun: row => {
            addChild(row);
          },
        },
        {
          name: "编辑",
          type: "primary",
          clickFun: row => {
            edit(row);
          },
        },
        {
          name: "删除",
          type: "danger",
          clickFun: row => {
            handleDelete(row);
          },
        },
      ],
    },
  ];
  const dataList = ref([]);
  const dialogVisible = ref(false);
  const dialogTitle = ref("");
  const parentSubjectLabel = ref("顶级科目");
  const formRef = ref(null);
  const tableRef = ref(null);
  const isEdit = ref(false);
  const loading = ref(false);
  const form = reactive({
    id: undefined,
    parentId: null,
    subjectCode: "",
    subjectName: "",
    subjectType: "",
    balanceDirection: "借方",
    status: 0,
    remark: "",
  });
  const rules = {
    subjectCode: [{ required: true, message: "请输入科目编码", trigger: "blur" }],
    subjectName: [{ required: true, message: "请输入科目名称", trigger: "blur" }],
    subjectType: [
      { required: true, message: "请选择科目类型", trigger: "change" },
    ],
  };
  const getSubjectTypeType = type => {
    const map = {
      èµ„产类: "success",
      è´Ÿå€ºç±»: "danger",
      æƒç›Šç±»: "warning",
      æˆæœ¬ç±»: "info",
      æŸç›Šç±»: "primary",
    };
    return map[type] || "";
  };
  const getTableData = () => {
    loading.value = true;
    const query = {
      current: pagination.currentPage,
      size: pagination.pageSize,
      ...filters,
    };
    listAccountSubject(query).then(response => {
      dataList.value = response.data.records || [];
      loading.value = false;
    }).catch(() => {
      loading.value = false;
    });
  };
  const resetFilters = () => {
    filters.subjectCode = "";
    filters.subjectName = "";
    filters.subjectType = "";
    pagination.currentPage = 1;
    getTableData();
  };
  const changePage = obj => {
    pagination.currentPage = obj.page;
    pagination.pageSize = obj.limit;
    getTableData();
  };
  const buildParentSubjectLabel = parentRow => {
    if (!parentRow) {
      return "顶级科目";
    }
    const code = parentRow.subjectCode || "";
    const name = parentRow.subjectName || "";
    return `${code} ${name}`.trim();
  };
  const resetForm = ({ parentId = null, parentRow = null } = {}) => {
    Object.assign(form, {
      id: undefined,
      parentId,
      subjectCode: "",
      subjectName: "",
      subjectType: "",
      balanceDirection: "借方",
      status: 0,
      remark: "",
    });
    parentSubjectLabel.value = buildParentSubjectLabel(parentRow);
  };
  const add = () => {
    isEdit.value = false;
    dialogTitle.value = "新增科目";
    resetForm({ parentId: null, parentRow: null });
    dialogVisible.value = true;
  };
  const addChild = row => {
    isEdit.value = false;
    dialogTitle.value = "新增子科目";
    resetForm({ parentId: row.id, parentRow: row });
    form.subjectType = row.subjectType || "";
    form.balanceDirection = row.balanceDirection || "借方";
    dialogVisible.value = true;
  };
  const findSubjectById = (nodes, id) => {
    for (const item of nodes || []) {
      if (item.id === id) {
        return item;
      }
      if (item.children && item.children.length > 0) {
        const found = findSubjectById(item.children, id);
        if (found) {
          return found;
        }
      }
    }
    return null;
  };
  const edit = row => {
    isEdit.value = true;
    dialogTitle.value = "编辑科目";
    Object.assign(form, row);
    form.parentId = row.parentId ?? null;
    const parentRow =
      row.parentId === null || row.parentId === undefined
        ? null
        : findSubjectById(dataList.value, row.parentId);
    parentSubjectLabel.value = parentRow
      ? buildParentSubjectLabel(parentRow)
      : row.parentId
      ? `上级ID: ${row.parentId}`
      : buildParentSubjectLabel(null);
    dialogVisible.value = true;
  };
  const submitForm = () => {
    formRef.value.validate(valid => {
      if (valid) {
        if (isEdit.value) {
          updateAccountSubject(form).then(() => {
            ElMessage.success("编辑成功");
            dialogVisible.value = false;
            getTableData();
          });
        } else {
          addAccountSubject(form).then(() => {
            ElMessage.success("新增成功");
            dialogVisible.value = false;
            getTableData();
          });
        }
      }
    });
  };
  const handleDelete = row => {
    const ids = row.id;
    ElMessageBox.confirm("确认删除该科目吗?", "提示", {
      confirmButtonText: "确定",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        return delAccountSubject(ids);
      })
      .then(() => {
        ElMessage.success("删除成功");
        getTableData();
      });
  };
  const handleOut = () => {
    proxy.download(
      "accountSubject/export",
      {
        ...filters,
      },
      `account_subject_${new Date().getTime()}.xlsx`
    );
  };
  onMounted(() => {
    getTableData();
  });
</script>
<style lang="scss" scoped>
  .actions {
    display: flex;
    justify-content: space-between;
    margin-bottom: 15px;
  }
  .subject-table {
    border-radius: 8px;
    overflow: hidden;
    :deep(.el-table__row) {
      transition: background-color 0.3s;
    }
    :deep(.el-table__row:hover) {
      background-color: #f5f7fa;
    }
    .subject-code {
      color: #606266;
    }
    .subject-name {
      font-weight: 500;
      &.is-parent {
        color: #409eff;
      }
    }
  }
</style>
src/views/financialManagement/payable/input-invoice.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,945 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="发票号码:">
        <el-input v-model="filters.invoiceNumber" placeholder="请输入发票号码" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="供应商:">
        <el-select v-model="filters.supplierId" placeholder="请选择供应商" clearable filterable style="width: 200px;">
          <el-option
            v-for="item in supplierList"
            :key="item.id"
            :label="item.supplierName"
            :value="item.id"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="开票日期:">
        <el-date-picker
          v-model="filters.dateRange"
          type="daterange"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          clearable
          style="width: 240px;"
        />
      </el-form-item>
      <el-form-item label="状态:">
        <el-select v-model="filters.status" placeholder="请选择状态" clearable style="width: 150px;">
          <el-option label="正常" :value="0" />
          <el-option label="作废" :value="1" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button type="primary" @click="add" icon="Plus">录入发票</el-button>
          <el-button @click="handleExport" icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      >
        <template #amount="{ row }">
          <span class="text-primary">Â¥{{ formatMoney(row.amount) }}</span>
        </template>
        <template #taxAmount="{ row }">
          <span class="text-danger">Â¥{{ formatMoney(row.taxAmount) }}</span>
        </template>
        <template #totalAmount="{ row }">
          <span class="text-success">Â¥{{ formatMoney(row.totalAmount) }}</span>
        </template>
        <template #status="{ row }">
          <el-tag :type="getStatusType(row.status)" effect="light" round>
            {{ getStatusLabel(row.status) }}
          </el-tag>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="warning" link @click="handleCancel(row)" v-if="isNormalStatus(row.status)">作废</el-button>
          <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog
      :title="dialogTitle"
      v-model="dialogVisible"
      width="800px"
      :operation-type="isView ? 'detail' : ''"
      @confirm="submitForm"
      @cancel="closeDialog"
    >
      <el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
        <el-row v-if="isView" :gutter="20">
          <el-col :span="12">
            <el-form-item label="状态">
              <el-tag :type="getStatusType(form.status)" effect="light" round>
                {{ getStatusLabel(form.status) }}
              </el-tag>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="发票号码" prop="invoiceNo">
              <el-input v-model="form.invoiceNo" placeholder="请输入发票号码" :disabled="isView" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="供应商" prop="supplierId">
              <el-select
                v-model="form.supplierId"
                placeholder="请选择供应商"
                style="width: 100%;"
                filterable
                :disabled="isView"
                @change="handleSupplierChange"
              >
                <el-option
                  v-for="item in supplierList"
                  :key="item.id"
                  :label="item.supplierName"
                  :value="item.id"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="关联入库单" prop="stockInRecordIds">
              <el-input
                :model-value="inboundBatchDisplayText"
                placeholder="请先选择供应商"
                readonly
                :disabled="!form.supplierId || isView"
                class="inbound-batch-input"
                @click="handleInboundInputClick"
              >
                <template v-if="!isView" #append>
                  <el-button
                    :disabled="!form.supplierId"
                    :loading="inboundBatchLoading"
                    @click.stop="openInboundSelectDialog"
                  >
                    é€‰æ‹©
                  </el-button>
                </template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="开票日期" prop="invoiceDate">
              <el-date-picker
                v-model="form.invoiceDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
                style="width: 100%;"
                :disabled="isView"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="发票类型" prop="invoiceType">
              <el-select
                v-model="form.invoiceType"
                placeholder="请选择发票类型"
                style="width: 100%;"
                :disabled="isView"
              >
                <el-option label="增值税专用发票" value="增值税专用发票" />
                <el-option label="增值税普通发票" value="增值税普通发票" />
                <el-option label="电子发票" value="电子发票" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="税率" prop="taxRate">
              <el-select
                v-model="form.taxRate"
                placeholder="请选择税率"
                style="width: 100%;"
                :disabled="isView"
                @change="handleTaxRateChange"
              >
                <el-option
                  v-for="dict in tax_rate"
                  :key="dict.value"
                  :label="dict.label"
                  :value="Number(dict.value)"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="8">
            <el-form-item label="金额(不含税)" prop="amount">
              <el-input-number
                v-model="form.amount"
                :min="0"
                :precision="2"
                style="width: 100%;"
                :disabled="isView"
                placeholder="根据入库单含税金额自动换算,可修改"
                @change="calculateTaxFromExclusive"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="税额">
              <el-input-number
                v-model="form.taxAmount"
                :min="0"
                :precision="2"
                :controls="false"
                style="width: 100%;"
                disabled
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="价税合计">
              <el-input-number
                v-model="form.totalAmount"
                :min="0"
                :precision="2"
                :controls="false"
                style="width: 100%;"
                disabled
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="发票内容" prop="content">
          <el-input v-model="form.content" type="textarea" :rows="3" placeholder="请输入发票内容" :disabled="isView" />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" :disabled="isView" />
        </el-form-item>
      </el-form>
      <template v-if="!isView" #footer>
        <el-button type="primary" :loading="submitLoading" @click="submitForm">确定</el-button>
        <el-button @click="closeDialog">取消</el-button>
      </template>
    </FormDialog>
    <el-dialog
      v-model="inboundSelectVisible"
      title="选择入库单号"
      width="1100px"
      append-to-body
      destroy-on-close
      :close-on-click-modal="false"
      @closed="handleInboundDialogClosed"
    >
      <el-table
        ref="inboundTableRef"
        v-loading="inboundBatchLoading"
        :data="inboundBatchList"
        row-key="id"
        border
        stripe
        max-height="480"
        @selection-change="handleInboundDialogSelectionChange"
      >
        <el-table-column type="selection" width="55" align="center" />
        <el-table-column prop="inboundBatches" label="入库单号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="supplierName" label="供应商" min-width="120" show-overflow-tooltip />
        <el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
        <el-table-column prop="specificationModel" label="规格型号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="purchaseContractNumber" label="采购订单号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="inboundDate" label="入库日期" width="110" align="center" />
        <el-table-column prop="inboundAmount" label="入库金额(含税)" width="120" align="right">
          <template #default="{ row }">Â¥{{ formatMoney(getInboundRowTaxInclusiveAmount(row)) }}</template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button type="primary" @click="confirmInboundSelection">确定</el-button>
        <el-button @click="inboundSelectVisible = false">取消</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, getCurrentInstance } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { getOptions } from "@/api/procurementManagement/procurementLedger.js";
import {
  getInboundBatchesBySupplier,
  addAccountPurchaseInvoice,
  listPageAccountPurchaseInvoice,
  cancelAccountPurchaseInvoice,
  deleteAccountPurchaseInvoice,
} from "@/api/financialManagement/accountPurchaseInvoice.js";
defineOptions({
  name: "进项发票",
});
const { proxy } = getCurrentInstance();
const { tax_rate } = proxy.useDict("tax_rate");
const filters = reactive({
  invoiceNumber: "",
  supplierId: "",
  dateRange: [],
  status: "",
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "发票号码", prop: "invoiceNo", width: "140" },
  { label: "供应商", prop: "supplierName", width: "180" },
  { label: "开票日期", prop: "invoiceDate", width: "120" },
  { label: "金额", prop: "amount", dataType: "slot", slot: "amount" },
  { label: "税额", prop: "taxAmount", dataType: "slot", slot: "taxAmount" },
  { label: "价税合计", prop: "totalAmount", dataType: "slot", slot: "totalAmount" },
  { label: "发票类型", prop: "invoiceType", width: "130" },
  { label: "状态", prop: "status", dataType: "slot", slot: "status", width: "90", align: "center" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "200", fixed: "right" },
];
const dataList = ref([]);
const tableLoading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const isView = ref(false);
const submitLoading = ref(false);
const supplierList = ref([]);
const inboundBatchList = ref([]);
const inboundBatchOptions = ref([]);
const inboundBatchLoading = ref(false);
const inboundSelectVisible = ref(false);
const inboundTableRef = ref(null);
const dialogInboundSelection = ref([]);
const STATUS_LABEL_MAP = { 0: "正常", 1: "作废" };
const STATUS_TYPE_MAP = { 0: "success", 1: "info" };
const form = reactive({
  invoiceNo: "",
  supplierId: "",
  invoiceDate: "",
  invoiceType: "增值税专用发票",
  taxRate: 13,
  amount: 0,
  taxAmount: 0,
  totalAmount: 0,
  content: "",
  remark: "",
  stockInRecordIds: [],
  inboundBatches: "",
  storageAttachmentId: undefined,
  status: 0,
});
const rules = {
  invoiceNo: [{ required: true, message: "请输入发票号码", trigger: "blur" }],
  supplierId: [{ required: true, message: "请选择供应商", trigger: "change" }],
  stockInRecordIds: [{ required: true, type: "array", min: 1, message: "请选择关联入库单", trigger: "change" }],
  invoiceDate: [{ required: true, message: "请选择开票日期", trigger: "change" }],
  invoiceType: [{ required: true, message: "请选择发票类型", trigger: "change" }],
  taxRate: [{ required: true, message: "请选择税率", trigger: "change" }],
  amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const normalizeStatus = (status) => {
  if (status === undefined || status === null || status === "") return 0;
  const num = Number(status);
  return Number.isNaN(num) ? 0 : num;
};
const isNormalStatus = (status) => normalizeStatus(status) === 0;
const getStatusLabel = (status) => STATUS_LABEL_MAP[normalizeStatus(status)] ?? "正常";
const getStatusType = (status) => STATUS_TYPE_MAP[normalizeStatus(status)] ?? "success";
const parseStockInRecordIds = (value) => {
  if (!value) return [];
  if (Array.isArray(value)) return value;
  return String(value)
    .split(/[,,]/)
    .map((s) => s.trim())
    .filter(Boolean)
    .map((s) => (/^\d+$/.test(s) ? Number(s) : s));
};
const formatInboundBatches = (value) => {
  if (value === undefined || value === null || value === "") return "";
  if (Array.isArray(value)) return value.filter(Boolean).join("、");
  return String(value)
    .split(/[,,]/)
    .map((s) => s.trim())
    .filter(Boolean)
    .join("、");
};
const isSameInboundId = (a, b) => String(a) === String(b);
const getInboundRowId = (row) => row?.id ?? row?.stockInRecordId;
/** å…¥åº“单金额为含税价 */
const getInboundRowTaxInclusiveAmount = (row) =>
  Number(row?.inboundAmount ?? row?.taxInclusivePrice ?? row?.totalAmount ?? 0);
const normalizeInboundBatchOptions = (data) => {
  const list = Array.isArray(data) ? data : [];
  return list.map((item, index) => {
    if (typeof item === "string" || typeof item === "number") {
      const text = String(item);
      return { label: text, value: text, inboundAmount: 0 };
    }
    const label =
      item.inboundBatches ?? item.batchNo ?? item.inboundNo ?? item.label ?? `入库单${index + 1}`;
    const value = item.id ?? item.stockInRecordId ?? label;
    return {
      label: String(label),
      value,
      inboundAmount: getInboundRowTaxInclusiveAmount(item),
    };
  });
};
/** ä¸å«ç¨Žé‡‘额变更:税额、价税合计正向计算 */
const calculateTaxFromExclusive = () => {
  form.taxAmount = Number((form.amount * form.taxRate / 100).toFixed(2));
  form.totalAmount = Number((form.amount + form.taxAmount).toFixed(2));
};
/** ä»·ç¨Žåˆè®¡å˜æ›´ï¼šæŒ‰ç¨ŽçŽ‡åç®—ä¸å«ç¨Žé‡‘é¢ã€ç¨Žé¢ */
const calculateTaxFromInclusive = (inclusiveTotal) => {
  const total = Number(inclusiveTotal ?? form.totalAmount ?? 0);
  if (total <= 0) {
    form.amount = 0;
    form.taxAmount = 0;
    form.totalAmount = 0;
    return;
  }
  const rate = Number(form.taxRate) / 100;
  form.totalAmount = Number(total.toFixed(2));
  form.amount = Number((form.totalAmount / (1 + rate)).toFixed(2));
  form.taxAmount = Number((form.totalAmount - form.amount).toFixed(2));
};
const handleTaxRateChange = () => {
  if (form.totalAmount > 0) {
    calculateTaxFromInclusive(form.totalAmount);
  } else {
    calculateTaxFromExclusive();
  }
};
/** æ ¹æ®å·²é€‰å…¥åº“单汇总含税金额,反算不含税金额与税额 */
const syncInvoiceAmount = () => {
  const selected = form.stockInRecordIds || [];
  const sumFromOptions = inboundBatchOptions.value
    .filter((opt) => selected.some((id) => isSameInboundId(id, opt.value)))
    .reduce((acc, opt) => acc + (Number(opt.inboundAmount) || 0), 0);
  let taxInclusiveSum = sumFromOptions;
  if (taxInclusiveSum <= 0 && selected.length) {
    taxInclusiveSum = inboundBatchList.value
      .filter((row) => selected.some((id) => isSameInboundId(id, getInboundRowId(row))))
      .reduce((acc, row) => acc + getInboundRowTaxInclusiveAmount(row), 0);
  }
  calculateTaxFromInclusive(taxInclusiveSum > 0 ? Number(taxInclusiveSum.toFixed(2)) : 0);
};
const inboundBatchDisplayText = computed(() => {
  if (form.inboundBatches) return form.inboundBatches;
  const ids = form.stockInRecordIds || [];
  if (!ids.length) return "";
  const labels = inboundBatchOptions.value
    .filter((opt) => ids.some((id) => isSameInboundId(id, opt.value)))
    .map((opt) => opt.label);
  if (labels.length) return labels.join("、");
  return ids.join("、");
});
const normalizeTableRow = (row) => ({
  ...row,
  invoiceNo: row.invoiceNumber ?? row.invoiceNo,
  invoiceDate: row.issueDate ?? row.invoiceDate,
  amount: row.taxExclusivelPrice ?? row.amount,
  taxAmount: row.taxPrice ?? row.taxAmount,
  totalAmount: row.taxInclusivePrice ?? row.totalAmount,
  content: row.invoiceContent ?? row.content,
  status: normalizeStatus(row.status),
  stockInRecordIds: row.stockInRecordIds ?? "",
  inboundBatches: formatInboundBatches(row.inboundBatches),
});
const toFormNumber = (val) => {
  const n = Number(val);
  return Number.isFinite(n) ? n : 0;
};
const resolveFormAmounts = (row) => {
  let amount = toFormNumber(row.taxExclusivelPrice ?? row.amount);
  let taxAmount = toFormNumber(row.taxPrice ?? row.taxAmount);
  let totalAmount = toFormNumber(row.taxInclusivePrice ?? row.totalAmount);
  const taxRate = toFormNumber(row.taxRate) || 13;
  if (totalAmount > 0 && amount === 0 && taxAmount === 0) {
    amount = Number((totalAmount / (1 + taxRate / 100)).toFixed(2));
    taxAmount = Number((totalAmount - amount).toFixed(2));
  } else if (totalAmount > 0 && amount > 0 && taxAmount === 0) {
    taxAmount = Number((totalAmount - amount).toFixed(2));
  } else if (amount > 0 && taxAmount === 0 && totalAmount === 0) {
    taxAmount = Number((amount * taxRate / 100).toFixed(2));
    totalAmount = Number((amount + taxAmount).toFixed(2));
  } else if (amount > 0 && taxAmount > 0 && totalAmount === 0) {
    totalAmount = Number((amount + taxAmount).toFixed(2));
  }
  return { amount, taxAmount, totalAmount };
};
const fillFormFromRow = (row) => {
  const stockInRecordIds = parseStockInRecordIds(row.stockInRecordIds);
  const { amount, taxAmount, totalAmount } = resolveFormAmounts(row);
  Object.assign(form, {
    invoiceNo: row.invoiceNo ?? row.invoiceNumber ?? "",
    supplierId: row.supplierId,
    invoiceDate: row.invoiceDate ?? row.issueDate ?? "",
    invoiceType: row.invoiceType ?? "增值税专用发票",
    taxRate: row.taxRate ?? 13,
    amount,
    taxAmount,
    totalAmount,
    content: row.content ?? row.invoiceContent ?? "",
    remark: row.remark ?? "",
    stockInRecordIds,
    inboundBatches: formatInboundBatches(row.inboundBatches),
    storageAttachmentId: row.storageAttachmentId,
    status: normalizeStatus(row.status),
  });
};
const buildCancelPayload = (row) => ({
  id: row.id,
  invoiceNumber: row.invoiceNumber ?? row.invoiceNo,
  taxRate: row.taxRate,
  invoiceType: row.invoiceType,
  issueDate: row.issueDate ?? row.invoiceDate,
  taxExclusivelPrice: row.taxExclusivelPrice ?? row.amount,
  taxPrice: row.taxPrice ?? row.taxAmount,
  taxInclusivePrice: row.taxInclusivePrice ?? row.totalAmount,
  remark: row.remark ?? "",
  invoiceContent: row.invoiceContent ?? row.content,
  supplierId: row.supplierId,
  storageAttachmentId: row.storageAttachmentId,
  stockInRecordIds: row.stockInRecordIds ?? "",
  status: 1,
});
const buildSubmitPayload = () => ({
  invoiceNumber: form.invoiceNo,
  supplierId: form.supplierId,
  issueDate: form.invoiceDate,
  invoiceType: form.invoiceType,
  taxRate: form.taxRate,
  taxExclusivelPrice: form.amount,
  taxPrice: form.taxAmount,
  taxInclusivePrice: form.totalAmount,
  invoiceContent: form.content,
  remark: form.remark || "",
  stockInRecordIds: (form.stockInRecordIds || []).join(","),
  status: 0,
  storageAttachmentId: form.storageAttachmentId,
});
const getSupplierList = () => {
  getOptions().then((res) => {
    if (res.code === 200) {
      supplierList.value = res.data ?? [];
    }
  });
};
const appendFilterParams = (params) => {
  if (filters.invoiceNumber) {
    params.invoiceNumber = filters.invoiceNumber;
  }
  if (filters.supplierId) {
    params.supplierId = filters.supplierId;
  }
  if (filters.dateRange?.length === 2) {
    params.startDate = filters.dateRange[0];
    params.endDate = filters.dateRange[1];
  }
  if (filters.status !== "" && filters.status != null) {
    params.status = filters.status;
  }
  return params;
};
const buildListParams = () =>
  appendFilterParams({
    current: pagination.currentPage,
    size: pagination.pageSize,
  });
const buildExportParams = () => appendFilterParams({});
const handleExport = () => {
  proxy.download(
    "/accountPurchaseInvoice/exportAccountPurchaseInvoice",
    buildExportParams(),
    `进项发票_${Date.now()}.xlsx`
  );
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountPurchaseInvoice(buildListParams())
    .then((res) => {
      if (res.code === 200) {
        const records = res.data?.records ?? [];
        dataList.value = records.map(normalizeTableRow);
        pagination.total = res.data?.total ?? 0;
      } else {
        dataList.value = [];
        pagination.total = 0;
        ElMessage.error(res.msg || "查询失败");
      }
    })
    .catch(() => {
      dataList.value = [];
      pagination.total = 0;
      ElMessage.error("查询失败");
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
const resetFilters = () => {
  filters.invoiceNumber = "";
  filters.supplierId = "";
  filters.dateRange = [];
  filters.status = "";
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ page, limit }) => {
  pagination.currentPage = page;
  pagination.pageSize = limit;
  getTableData();
};
const closeDialog = () => {
  dialogVisible.value = false;
  isView.value = false;
  inboundSelectVisible.value = false;
};
const resetForm = () => {
  Object.assign(form, {
    invoiceNo: "",
    supplierId: "",
    invoiceDate: new Date().toISOString().split("T")[0],
    invoiceType: "增值税专用发票",
    taxRate: 13,
    amount: 0,
    taxAmount: 0,
    totalAmount: 0,
    content: "",
    remark: "",
    stockInRecordIds: [],
    inboundBatches: "",
    storageAttachmentId: undefined,
    status: 0,
  });
  inboundBatchList.value = [];
  inboundBatchOptions.value = [];
};
const add = () => {
  isView.value = false;
  dialogTitle.value = "录入发票";
  resetForm();
  dialogVisible.value = true;
};
const view = (row) => {
  isView.value = true;
  dialogTitle.value = "查看发票";
  fillFormFromRow(row);
  if (row.supplierId) {
    loadInboundBatches(row.supplierId, true, false);
  }
  dialogVisible.value = true;
};
const handleCancel = (row) => {
  ElMessageBox.confirm(`确认作废发票「${row.invoiceNo ?? row.invoiceNumber}」吗?`, "作废确认", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    cancelAccountPurchaseInvoice(buildCancelPayload(row))
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("作废成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "作废失败");
        }
      })
      .catch(() => {
        ElMessage.error("作废失败");
      });
  });
};
const handleDelete = (row) => {
  ElMessageBox.confirm(`确认删除发票「${row.invoiceNo ?? row.invoiceNumber}」吗?`, "删除确认", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    deleteAccountPurchaseInvoice([row.id])
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("删除成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "删除失败");
        }
      })
      .catch(() => {
        ElMessage.error("删除失败");
      });
  });
};
const submitForm = () => {
  formRef.value?.validate((valid) => {
    if (!valid) return;
    submitLoading.value = true;
    addAccountPurchaseInvoice(buildSubmitPayload())
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("录入成功");
          closeDialog();
          pagination.currentPage = 1;
          getTableData();
        } else {
          ElMessage.error(res.msg || "录入失败");
        }
      })
      .catch(() => {
        ElMessage.error("录入失败");
      })
      .finally(() => {
        submitLoading.value = false;
      });
  });
};
const ensureInboundOptionsForSelected = () => {
  const ids = form.stockInRecordIds || [];
  ids.forEach((id) => {
    const exists = inboundBatchOptions.value.some((opt) => isSameInboundId(opt.value, id));
    if (exists) return;
    const fromList = inboundBatchList.value.find((row) => isSameInboundId(getInboundRowId(row), id));
    if (fromList) {
      const [option] = normalizeInboundBatchOptions([fromList]);
      if (option) inboundBatchOptions.value.push(option);
      return;
    }
    inboundBatchOptions.value.push({
      label: String(id),
      value: id,
      inboundAmount: 0,
    });
  });
};
const restoreInboundTableSelection = () => {
  nextTick(() => {
    const table = inboundTableRef.value;
    if (!table) return;
    table.clearSelection();
    const selectedIds = new Set((form.stockInRecordIds || []).map((id) => String(id)));
    inboundBatchList.value.forEach((row) => {
      const rowId = getInboundRowId(row);
      if (rowId !== undefined && rowId !== null && selectedIds.has(String(rowId))) {
        table.toggleRowSelection(row, true);
      }
    });
  });
};
const loadInboundBatches = (supplierId, keepSelected = false, syncAmount = true) => {
  if (!supplierId) {
    inboundBatchList.value = [];
    inboundBatchOptions.value = [];
    if (!keepSelected) {
      form.stockInRecordIds = [];
      form.inboundBatches = "";
      form.amount = 0;
      form.taxAmount = 0;
      form.totalAmount = 0;
    }
    return Promise.resolve();
  }
  inboundBatchLoading.value = true;
  return getInboundBatchesBySupplier({ supplierId })
    .then((res) => {
      if (res.code === 200) {
        const list = res.data?.records ?? res.data ?? [];
        inboundBatchList.value = Array.isArray(list) ? list : [];
        inboundBatchOptions.value = normalizeInboundBatchOptions(list);
      } else {
        inboundBatchList.value = [];
        inboundBatchOptions.value = [];
      }
    })
    .catch(() => {
      inboundBatchList.value = [];
      inboundBatchOptions.value = [];
    })
    .finally(() => {
      inboundBatchLoading.value = false;
      if (keepSelected) {
        ensureInboundOptionsForSelected();
        restoreInboundTableSelection();
        if (syncAmount && !isView.value) {
          syncInvoiceAmount();
        }
      }
    });
};
const handleSupplierChange = (supplierId) => {
  form.stockInRecordIds = [];
  form.inboundBatches = "";
  form.amount = 0;
  form.taxAmount = 0;
  form.totalAmount = 0;
  loadInboundBatches(supplierId);
};
const handleInboundInputClick = () => {
  if (isView.value) return;
  openInboundSelectDialog();
};
const openInboundSelectDialog = () => {
  if (!form.supplierId || isView.value) return;
  inboundSelectVisible.value = true;
  loadInboundBatches(form.supplierId, true).then(() => {
    restoreInboundTableSelection();
  });
};
const handleInboundDialogSelectionChange = (selection) => {
  dialogInboundSelection.value = selection;
};
const confirmInboundSelection = () => {
  if (dialogInboundSelection.value.length === 0) {
    ElMessage.warning("请至少选择一条入库单");
    return;
  }
  form.stockInRecordIds = dialogInboundSelection.value
    .map((row) => getInboundRowId(row))
    .filter((id) => id !== undefined && id !== null);
  form.inboundBatches = dialogInboundSelection.value
    .map((row) => row.inboundBatches ?? row.batchNo ?? "")
    .filter(Boolean)
    .join("、");
  dialogInboundSelection.value.forEach((row) => {
    const [option] = normalizeInboundBatchOptions([row]);
    if (option && !inboundBatchOptions.value.some((opt) => isSameInboundId(opt.value, option.value))) {
      inboundBatchOptions.value.push(option);
    }
  });
  inboundSelectVisible.value = false;
  syncInvoiceAmount();
  formRef.value?.validateField("stockInRecordIds");
};
const handleInboundDialogClosed = () => {
  dialogInboundSelection.value = [];
};
onMounted(() => {
  getSupplierList();
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
.text-primary {
  color: #409eff;
  font-weight: bold;
}
.text-danger {
  color: #f56c6c;
  font-weight: bold;
}
.text-success {
  color: #67c23a;
  font-weight: bold;
}
.inbound-batch-input :deep(.el-input__wrapper) {
  cursor: pointer;
}
</style>
src/views/financialManagement/payable/payment.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,299 @@
<template>
  <div class="app-container">
    <el-form :model="filters"
             :inline="true">
      <el-form-item label="付款单号:">
        <el-input v-model="filters.paymentNumber"
                  placeholder="请输入付款单号"
                  clearable
                  style="width: 200px;" />
      </el-form-item>
      <el-form-item label="供应商:">
        <el-select v-model="filters.supplierId"
                   placeholder="请选择供应商"
                   clearable
                   filterable
                   style="width: 200px;">
          <el-option v-for="item in supplierList"
                     :key="item.id"
                     :label="item.supplierName"
                     :value="item.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="付款方式:">
        <el-select v-model="filters.paymentMethod"
                   placeholder="请选择付款方式"
                   clearable
                   style="width: 150px;">
          <el-option v-for="item in checkout_payment"
                     :key="item.value"
                     :label="item.label"
                     :value="item.value" />
        </el-select>
      </el-form-item>
      <el-form-item label="付款日期:">
        <el-date-picker v-model="filters.dateRange"
                        type="daterange"
                        value-format="YYYY-MM-DD"
                        format="YYYY-MM-DD"
                        range-separator="至"
                        start-placeholder="开始日期"
                        end-placeholder="结束日期"
                        clearable
                        style="width: 240px;" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary"
                   @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div>
          <el-statistic title="本页付款合计"
                        :value="totalPaymentAmount"
                        :precision="2"
                        prefix="Â¥" />
        </div>
        <div>
          <el-button @click="handleExport"
                     icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable rowKey="id"
                :column="columns"
                :tableData="dataList"
                :tableLoading="tableLoading"
                :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
                @pagination="changePage">
        <template #amount="{ row }">
          <span class="text-danger">Â¥{{ formatMoney(row.amount) }}</span>
        </template>
        <template #paymentMethod="{ row }">
          <el-tag>{{ getPaymentMethodLabel(row.paymentMethod) }}</el-tag>
        </template>
        <template #operation="{ row }">
          <el-button :disabled="row.accountStatemen"
                     type="danger"
                     link
                     @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
  </div>
</template>
<script setup>
  import { ref, reactive, computed, onMounted, getCurrentInstance } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { getOptions } from "@/api/procurementManagement/procurementLedger.js";
  import {
    listPageAccountPurchasePayment,
    deleteAccountPurchasePayment,
  } from "@/api/financialManagement/accountPurchasePayment.js";
  defineOptions({
    name: "付款单",
  });
  const { proxy } = getCurrentInstance();
  const { checkout_payment } = proxy.useDict("checkout_payment");
  const filters = reactive({
    paymentNumber: "",
    supplierId: "",
    paymentMethod: "",
    dateRange: [],
  });
  const pagination = reactive({
    currentPage: 1,
    pageSize: 10,
    total: 0,
  });
  const columns = [
    { label: "付款单号", prop: "paymentNumber", width: "150" },
    { label: "关联申请单", prop: "invoiceApplicationNo", width: "150" },
    { label: "供应商", prop: "supplierName", width: "180" },
    { label: "付款日期", prop: "paymentDate", width: "120" },
    { label: "付款金额", prop: "amount", dataType: "slot", slot: "amount" },
    {
      label: "付款方式",
      prop: "paymentMethod",
      dataType: "slot",
      slot: "paymentMethod",
      width: "120",
    },
    { label: "备注", prop: "remark", showOverflowTooltip: true },
    {
      label: "操作",
      prop: "operation",
      dataType: "slot",
      slot: "operation",
      width: "80",
      fixed: "right",
    },
  ];
  const dataList = ref([]);
  const tableLoading = ref(false);
  const supplierList = ref([]);
  const totalPaymentAmount = computed(() =>
    dataList.value.reduce((sum, item) => sum + Number(item.amount ?? 0), 0)
  );
  const formatMoney = value => {
    if (value === undefined || value === null) return "0.00";
    return Number(value)
      .toFixed(2)
      .replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  };
  const getPaymentMethodLabel = value => {
    if (value === undefined || value === null || value === "") return "-";
    const item = checkout_payment.value?.find(
      m => String(m.value) === String(value)
    );
    return item?.label ?? value;
  };
  const normalizeTableRow = row => ({
    ...row,
    paymentNumber: row.paymentNumber ?? row.paymentCode,
    invoiceApplicationNo: row.invoiceApplicationNo ?? row.applyCode ?? "",
    amount: row.paymentAmount ?? row.amount,
    bankAccountNum: row.bankAccountNum ?? row.bankAccount ?? "",
    bankAccountName: row.bankAccountName ?? row.bankName ?? "",
  });
  const getSupplierList = () => {
    getOptions().then(res => {
      if (res.code === 200) {
        supplierList.value = res.data ?? [];
      }
    });
  };
  const appendFilterParams = params => {
    if (filters.paymentNumber) {
      params.paymentNumber = filters.paymentNumber;
    }
    if (filters.supplierId) {
      params.supplierId = filters.supplierId;
    }
    if (filters.paymentMethod) {
      params.paymentMethod = filters.paymentMethod;
    }
    if (filters.dateRange?.length === 2) {
      params.startDate = filters.dateRange[0];
      params.endDate = filters.dateRange[1];
    }
    return params;
  };
  const buildListParams = () =>
    appendFilterParams({
      current: pagination.currentPage,
      size: pagination.pageSize,
    });
  const buildExportParams = () => appendFilterParams({});
  const handleExport = () => {
    proxy.download(
      "/accountPurchasePayment/exportAccountPurchasePayment",
      buildExportParams(),
      `付款单_${Date.now()}.xlsx`
    );
  };
  const getTableData = () => {
    tableLoading.value = true;
    listPageAccountPurchasePayment(buildListParams())
      .then(res => {
        if (res.code === 200) {
          dataList.value = (res.data?.records ?? []).map(normalizeTableRow);
          pagination.total = res.data?.total ?? 0;
        } else {
          dataList.value = [];
          pagination.total = 0;
          ElMessage.error(res.msg || "查询失败");
        }
      })
      .catch(() => {
        dataList.value = [];
        pagination.total = 0;
        ElMessage.error("查询失败");
      })
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const onSearch = () => {
    pagination.currentPage = 1;
    getTableData();
  };
  const resetFilters = () => {
    filters.paymentNumber = "";
    filters.supplierId = "";
    filters.paymentMethod = "";
    filters.dateRange = [];
    pagination.currentPage = 1;
    getTableData();
  };
  const changePage = ({ page, limit }) => {
    pagination.currentPage = page;
    pagination.pageSize = limit;
    getTableData();
  };
  const handleDelete = row => {
    ElMessageBox.confirm(`确认删除付款单「${row.paymentNumber}」吗?`, "提示", {
      confirmButtonText: "确定",
      cancelButtonText: "取消",
      type: "warning",
    }).then(() => {
      deleteAccountPurchasePayment([row.id])
        .then(res => {
          if (res.code === 200) {
            ElMessage.success("删除成功");
            getTableData();
          } else {
            ElMessage.error(res.msg || "删除失败");
          }
        })
        .catch(() => {
          ElMessage.error("删除失败");
        });
    });
  };
  onMounted(() => {
    getSupplierList();
    getTableData();
  });
</script>
<style lang="scss" scoped>
  .actions {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 15px;
  }
  .text-danger {
    color: #f56c6c;
    font-weight: bold;
  }
</style>
src/views/financialManagement/payable/paymentApply.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1016 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="申请单号:">
        <el-input v-model="filters.invoiceApplicationNo" placeholder="请输入申请单号" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="供应商:">
        <el-select v-model="filters.supplierId" placeholder="请选择供应商" clearable filterable style="width: 200px;">
          <el-option
            v-for="item in supplierList"
            :key="item.id"
            :label="item.supplierName"
            :value="item.id"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="审核状态:">
        <el-select v-model="filters.status" placeholder="请选择状态" clearable style="width: 150px;">
          <el-option label="待审核" :value="0" />
          <el-option label="审核通过" :value="1" />
          <el-option label="审核不通过" :value="2" />
        </el-select>
      </el-form-item>
      <el-form-item label="申请日期:">
        <el-date-picker
          v-model="filters.dateRange"
          type="daterange"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          clearable
          style="width: 240px;"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button type="primary" @click="add" icon="Plus">新增申请</el-button>
          <el-button @click="handleExport" icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      >
        <template #amount="{ row }">
          <span class="text-danger">Â¥{{ formatMoney(row.amount) }}</span>
        </template>
        <template #paymentMethod="{ row }">
          <el-tag>{{ getPaymentMethodLabel(row.paymentMethod) }}</el-tag>
        </template>
        <template #status="{ row }">
          <el-tag :type="getStatusType(row.status)">{{ getStatusLabel(row.status) }}</el-tag>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="primary" link @click="edit(row)" v-if="isPendingStatus(row.status)">编辑</el-button>
          <el-button type="success" link @click="handleAudit(row)" v-if="isPendingStatus(row.status)">审核</el-button>
          <el-button type="warning" link @click="openPaymentDialog(row)" v-if="isApprovedStatus(row.status)">付款</el-button>
          <el-button type="danger" link @click="handleDelete(row)" v-if="isPendingStatus(row.status)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog
      :title="dialogTitle"
      v-model="dialogVisible"
      width="800px"
      :operation-type="isView ? 'detail' : ''"
      @confirm="submitForm"
      @cancel="closeDialog"
    >
      <el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
        <el-row v-if="isView" :gutter="20">
          <el-col :span="12">
            <el-form-item label="审核状态">
              <el-tag :type="getStatusType(form.status)">{{ getStatusLabel(form.status) }}</el-tag>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="申请单号" prop="invoiceApplicationNo">
              <el-input v-model="form.invoiceApplicationNo" placeholder="系统自动生成" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="供应商" prop="supplierId">
              <el-select
                v-model="form.supplierId"
                placeholder="请选择供应商"
                style="width: 100%;"
                filterable
                :disabled="isEdit || isView"
                @change="handleSupplierChange"
              >
                <el-option
                  v-for="item in supplierList"
                  :key="item.id"
                  :label="item.supplierName"
                  :value="item.id"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="关联入库单" prop="stockInRecordIds">
              <el-input
                :model-value="inboundBatchDisplayText"
                placeholder="请先选择供应商"
                readonly
                :disabled="!form.supplierId || isEdit || isView"
                class="inbound-batch-input"
                @click="handleInboundInputClick"
              >
                <template v-if="!isEdit && !isView" #append>
                  <el-button
                    :disabled="!form.supplierId"
                    :loading="inboundBatchLoading"
                    @click.stop="openInboundSelectDialog"
                  >
                    é€‰æ‹©
                  </el-button>
                </template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="申请日期" prop="applyDate">
              <el-date-picker
                v-model="form.applyDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
                style="width: 100%;"
                :disabled="isView"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="付款金额" prop="paymentAmount">
              <el-input-number
                v-model="form.paymentAmount"
                :min="0"
                :precision="2"
                style="width: 100%;"
                :disabled="isView"
                placeholder="根据入库单自动汇总,可修改"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="付款方式" prop="paymentMethod">
              <el-select
                v-model="form.paymentMethod"
                placeholder="请选择付款方式"
                style="width: 100%;"
                :disabled="isView"
              >
                <el-option
                  v-for="item in checkout_payment"
                  :key="item.value"
                  :label="item.label"
                  :value="item.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="付款事由" prop="paymentContent">
          <el-input
            v-model="form.paymentContent"
            type="textarea"
            :rows="3"
            placeholder="请输入付款事由"
            :disabled="isView"
          />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" :disabled="isView" />
        </el-form-item>
      </el-form>
      <template v-if="!isView" #footer>
        <el-button type="primary" :loading="submitLoading" @click="submitForm">确定</el-button>
        <el-button @click="closeDialog">取消</el-button>
      </template>
    </FormDialog>
    <FormDialog
      title="付款"
      v-model="paymentDialogVisible"
      width="800px"
      @confirm="submitPayment"
      @cancel="paymentDialogVisible = false"
    >
      <el-form :model="paymentForm" :rules="paymentRules" ref="paymentFormRef" label-width="120px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="付款单号" prop="paymentNumber">
              <el-input v-model="paymentForm.paymentNumber" placeholder="系统自动生成" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="关联申请单" prop="invoiceApplicationNo">
              <el-input v-model="paymentForm.invoiceApplicationNo" disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="供应商">
              <el-input v-model="paymentForm.supplierName" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="付款日期" prop="paymentDate">
              <el-date-picker
                v-model="paymentForm.paymentDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
                style="width: 100%;"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="付款金额" prop="paymentAmount">
              <el-input-number
                v-model="paymentForm.paymentAmount"
                :min="0"
                :precision="2"
                style="width: 100%;"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="付款方式" prop="paymentMethod">
              <el-select v-model="paymentForm.paymentMethod" placeholder="请选择付款方式" style="width: 100%;">
                <el-option
                  v-for="item in checkout_payment"
                  :key="item.value"
                  :label="item.label"
                  :value="item.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row v-if="isBankTransferPayment(paymentForm.paymentMethod)" :gutter="20">
          <el-col :span="12">
            <el-form-item label="银行账号" prop="bankAccount">
              <el-input v-model="paymentForm.bankAccount" placeholder="银行账号" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="开户行" prop="bankName">
              <el-input v-model="paymentForm.bankName" placeholder="开户行" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="paymentForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="primary" :loading="paymentSubmitLoading" @click="submitPayment">确定</el-button>
        <el-button @click="paymentDialogVisible = false">取消</el-button>
      </template>
    </FormDialog>
    <el-dialog
      v-model="inboundSelectVisible"
      title="选择入库单号"
      width="1100px"
      append-to-body
      destroy-on-close
      :close-on-click-modal="false"
      @closed="handleInboundDialogClosed"
    >
      <el-table
        ref="inboundTableRef"
        v-loading="inboundBatchLoading"
        :data="inboundBatchList"
        row-key="id"
        border
        stripe
        max-height="480"
        @selection-change="handleInboundDialogSelectionChange"
      >
        <el-table-column type="selection" width="55" align="center" />
        <el-table-column prop="inboundBatches" label="入库单号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="supplierName" label="供应商" min-width="120" show-overflow-tooltip />
        <el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
        <el-table-column prop="specificationModel" label="规格型号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="purchaseContractNumber" label="采购订单号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="inboundDate" label="入库日期" width="110" align="center" />
        <el-table-column prop="inboundAmount" label="入库金额(含税)" width="120" align="right">
          <template #default="{ row }">Â¥{{ formatMoney(getInboundRowTaxInclusiveAmount(row)) }}</template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button type="primary" @click="confirmInboundSelection">确定</el-button>
        <el-button @click="inboundSelectVisible = false">取消</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, getCurrentInstance } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { getOptions } from "@/api/procurementManagement/procurementLedger.js";
import {
  getInboundBatchesBySupplier,
  addAccountPaymentApplication,
  listPageAccountPaymentApplication,
  updateAccountPaymentApplication,
  auditAccountPaymentApplication,
  deleteAccountPaymentApplication,
} from "@/api/financialManagement/accountPaymentApplication.js";
import { addAccountPurchasePayment } from "@/api/financialManagement/accountPurchasePayment.js";
defineOptions({
  name: "付款申请",
});
const { proxy } = getCurrentInstance();
const { checkout_payment } = proxy.useDict("checkout_payment");
const filters = reactive({
  invoiceApplicationNo: "",
  supplierId: "",
  status: "",
  dateRange: [],
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "申请单号", prop: "applyCode", width: "150" },
  { label: "供应商", prop: "supplierName", width: "180" },
  { label: "付款金额", prop: "amount", dataType: "slot", slot: "amount" },
  { label: "付款方式", prop: "paymentMethod", dataType: "slot", slot: "paymentMethod", width: "120" },
  { label: "申请日期", prop: "applyDate", width: "120" },
  { label: "状态", prop: "status", dataType: "slot", slot: "status", width: "100" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "260", fixed: "right" },
];
const dataList = ref([]);
const tableLoading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const isEdit = ref(false);
const isView = ref(false);
const submitLoading = ref(false);
const currentId = ref(null);
const supplierList = ref([]);
const inboundBatchList = ref([]);
const inboundBatchOptions = ref([]);
const inboundBatchLoading = ref(false);
const inboundSelectVisible = ref(false);
const inboundTableRef = ref(null);
const dialogInboundSelection = ref([]);
const paymentDialogVisible = ref(false);
const paymentFormRef = ref(null);
const paymentSubmitLoading = ref(false);
const paymentForm = reactive({
  paymentNumber: "",
  invoiceApplicationNo: "",
  supplierName: "",
  supplierId: "",
  accountPaymentApplicationId: null,
  paymentDate: "",
  paymentAmount: 0,
  paymentMethod: "",
  bankAccount: "",
  bankName: "",
  remark: "",
});
const paymentRules = {
  paymentDate: [{ required: true, message: "请选择付款日期", trigger: "change" }],
  paymentAmount: [{ required: true, message: "请输入付款金额", trigger: "blur" }],
  paymentMethod: [{ required: true, message: "请选择付款方式", trigger: "change" }],
};
const STATUS_LABEL_MAP = { 0: "待审核", 1: "审核通过", 2: "审核不通过" };
const STATUS_TYPE_MAP = { 0: "warning", 1: "success", 2: "danger" };
const form = reactive({
  invoiceApplicationNo: "",
  supplierId: "",
  paymentAmount: 0,
  paymentMethod: "",
  applyDate: "",
  paymentContent: "",
  remark: "",
  stockInRecordIds: [],
  inboundBatches: "",
  status: 0,
});
const rules = {
  supplierId: [{ required: true, message: "请选择供应商", trigger: "change" }],
  stockInRecordIds: [{ required: true, type: "array", min: 1, message: "请选择关联入库单", trigger: "change" }],
  paymentAmount: [{ required: true, message: "请输入付款金额", trigger: "blur" }],
  paymentMethod: [{ required: true, message: "请选择付款方式", trigger: "change" }],
  applyDate: [{ required: true, message: "请选择申请日期", trigger: "change" }],
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const normalizeStatus = (status) => {
  if (status === undefined || status === null || status === "") return 0;
  const num = Number(status);
  return Number.isNaN(num) ? 0 : num;
};
const isPendingStatus = (status) => normalizeStatus(status) === 0;
const isApprovedStatus = (status) => normalizeStatus(status) === 1;
const isBankTransferPayment = (method) => {
  if (method === undefined || method === null || method === "") return false;
  const item = checkout_payment.value?.find((m) => String(m.value) === String(method));
  if (item?.label?.includes("银行")) return true;
  return String(method) === "bank_transfer" || String(method).toLowerCase().includes("bank");
};
const getStatusLabel = (status) => STATUS_LABEL_MAP[normalizeStatus(status)] ?? "待审核";
const getStatusType = (status) => STATUS_TYPE_MAP[normalizeStatus(status)] ?? "warning";
const getPaymentMethodLabel = (value) => {
  if (value === undefined || value === null || value === "") return "-";
  const item = checkout_payment.value?.find((m) => String(m.value) === String(value));
  return item?.label ?? value;
};
const getDefaultPaymentMethod = () => checkout_payment.value?.[0]?.value ?? "";
const parseStockInRecordIds = (value) => {
  if (!value) return [];
  if (Array.isArray(value)) return value;
  return String(value)
    .split(/[,,]/)
    .map((s) => s.trim())
    .filter(Boolean)
    .map((s) => (/^\d+$/.test(s) ? Number(s) : s));
};
const formatInboundBatches = (value) => {
  if (value === undefined || value === null || value === "") return "";
  if (Array.isArray(value)) return value.filter(Boolean).join("、");
  return String(value)
    .split(/[,,]/)
    .map((s) => s.trim())
    .filter(Boolean)
    .join("、");
};
const isSameInboundId = (a, b) => String(a) === String(b);
const getInboundRowId = (row) => row?.id ?? row?.stockInRecordId;
const getInboundRowTaxInclusiveAmount = (row) =>
  Number(row?.inboundAmount ?? row?.taxInclusivePrice ?? row?.totalAmount ?? row?.amount ?? 0);
const normalizeInboundBatchOptions = (data) => {
  const list = Array.isArray(data) ? data : [];
  return list.map((item, index) => {
    const label =
      item.inboundBatches ?? item.batchNo ?? item.inboundNo ?? `入库单${index + 1}`;
    const value = item.id ?? item.stockInRecordId ?? label;
    return {
      label: String(label),
      value,
      inboundAmount: getInboundRowTaxInclusiveAmount(item),
    };
  });
};
const syncPaymentAmount = () => {
  const selected = form.stockInRecordIds || [];
  let sum = inboundBatchOptions.value
    .filter((opt) => selected.some((id) => isSameInboundId(id, opt.value)))
    .reduce((acc, opt) => acc + (Number(opt.inboundAmount) || 0), 0);
  if (sum <= 0 && selected.length) {
    sum = inboundBatchList.value
      .filter((row) => selected.some((id) => isSameInboundId(id, getInboundRowId(row))))
      .reduce((acc, row) => acc + getInboundRowTaxInclusiveAmount(row), 0);
  }
  form.paymentAmount = sum > 0 ? Number(sum.toFixed(2)) : 0;
};
const inboundBatchDisplayText = computed(() => {
  if (form.inboundBatches) return form.inboundBatches;
  const ids = form.stockInRecordIds || [];
  if (!ids.length) return "";
  const labels = inboundBatchOptions.value
    .filter((opt) => ids.some((id) => isSameInboundId(id, opt.value)))
    .map((opt) => opt.label);
  if (labels.length) return labels.join("、");
  return ids.join("、");
});
const normalizeTableRow = (row) => ({
  ...row,
  applyCode: row.invoiceApplicationNo ?? row.applyCode,
  amount: row.paymentAmount ?? row.amount,
  reason: row.paymentContent ?? row.reason,
  status: normalizeStatus(row.status),
  stockInRecordIds: row.stockInRecordIds ?? "",
  inboundBatches: formatInboundBatches(row.inboundBatches),
});
const fillFormFromRow = (row) => {
  const stockInRecordIds = parseStockInRecordIds(row.stockInRecordIds);
  Object.assign(form, {
    invoiceApplicationNo: row.invoiceApplicationNo ?? row.applyCode ?? "",
    supplierId: row.supplierId,
    paymentAmount: Number(row.paymentAmount ?? row.amount ?? 0),
    paymentMethod: row.paymentMethod ?? getDefaultPaymentMethod(),
    applyDate: row.applyDate ?? "",
    paymentContent: row.paymentContent ?? row.reason ?? "",
    remark: row.remark ?? "",
    stockInRecordIds,
    inboundBatches: formatInboundBatches(row.inboundBatches),
    status: normalizeStatus(row.status),
  });
};
const buildPayloadFromRow = (row, statusOverride) => ({
  id: row.id,
  supplierId: row.supplierId,
  stockInRecordIds:
    typeof row.stockInRecordIds === "string"
      ? row.stockInRecordIds
      : (row.stockInRecordIds || []).join(","),
  invoiceApplicationNo: row.invoiceApplicationNo ?? row.applyCode ?? "",
  paymentMethod: row.paymentMethod,
  paymentContent: row.paymentContent ?? row.reason ?? "",
  applyDate: row.applyDate,
  remark: row.remark ?? "",
  status: statusOverride !== undefined ? statusOverride : normalizeStatus(row.status),
  paymentAmount: Number(row.paymentAmount ?? row.amount ?? 0),
});
const buildSubmitPayload = (forUpdate = false) => {
  const payload = {
    supplierId: form.supplierId,
    stockInRecordIds: (form.stockInRecordIds || []).join(","),
    invoiceApplicationNo: form.invoiceApplicationNo || "",
    paymentMethod: form.paymentMethod,
    paymentContent: form.paymentContent || "",
    applyDate: form.applyDate,
    remark: form.remark || "",
    status: 0,
    paymentAmount: form.paymentAmount,
  };
  if (forUpdate) {
    payload.id = currentId.value;
  }
  return payload;
};
const getSupplierList = () => {
  getOptions().then((res) => {
    if (res.code === 200) {
      supplierList.value = res.data ?? [];
    }
  });
};
const appendFilterParams = (params) => {
  if (filters.invoiceApplicationNo) {
    params.invoiceApplicationNo = filters.invoiceApplicationNo;
  }
  if (filters.supplierId) {
    params.supplierId = filters.supplierId;
  }
  if (filters.status !== "" && filters.status != null) {
    params.status = filters.status;
  }
  if (filters.dateRange?.length === 2) {
    params.startDate = filters.dateRange[0];
    params.endDate = filters.dateRange[1];
  }
  return params;
};
const buildListParams = () =>
  appendFilterParams({
    current: pagination.currentPage,
    size: pagination.pageSize,
  });
const buildExportParams = () => appendFilterParams({});
const handleExport = () => {
  proxy.download(
    "/accountPaymentApplication/exportAccountPaymentApplication",
    buildExportParams(),
    `付款申请_${Date.now()}.xlsx`
  );
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountPaymentApplication(buildListParams())
    .then((res) => {
      if (res.code === 200) {
        dataList.value = (res.data?.records ?? []).map(normalizeTableRow);
        pagination.total = res.data?.total ?? 0;
      } else {
        dataList.value = [];
        pagination.total = 0;
        ElMessage.error(res.msg || "查询失败");
      }
    })
    .catch(() => {
      dataList.value = [];
      pagination.total = 0;
      ElMessage.error("查询失败");
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
const resetFilters = () => {
  filters.invoiceApplicationNo = "";
  filters.supplierId = "";
  filters.status = "";
  filters.dateRange = [];
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ page, limit }) => {
  pagination.currentPage = page;
  pagination.pageSize = limit;
  getTableData();
};
const closeDialog = () => {
  dialogVisible.value = false;
  isView.value = false;
  isEdit.value = false;
  inboundSelectVisible.value = false;
};
const resetForm = () => {
  Object.assign(form, {
    invoiceApplicationNo: "",
    supplierId: "",
    paymentAmount: 0,
    paymentMethod: getDefaultPaymentMethod(),
    applyDate: new Date().toISOString().split("T")[0],
    paymentContent: "",
    remark: "",
    stockInRecordIds: [],
    inboundBatches: "",
    status: 0,
  });
  inboundBatchList.value = [];
  inboundBatchOptions.value = [];
};
const add = () => {
  isEdit.value = false;
  isView.value = false;
  dialogTitle.value = "新增付款申请";
  resetForm();
  dialogVisible.value = true;
};
const edit = (row) => {
  isEdit.value = true;
  isView.value = false;
  currentId.value = row.id;
  dialogTitle.value = "编辑付款申请";
  fillFormFromRow(row);
  dialogVisible.value = true;
};
const view = (row) => {
  isView.value = true;
  isEdit.value = false;
  dialogTitle.value = "查看付款申请";
  fillFormFromRow(row);
  if (row.supplierId) {
    loadInboundBatches(row.supplierId, true, false);
  }
  dialogVisible.value = true;
};
const submitAudit = (row, status) => {
  auditAccountPaymentApplication(buildPayloadFromRow(row, status))
    .then((res) => {
      if (res.code === 200) {
        ElMessage.success(status === 1 ? "审核通过" : "审核不通过");
        getTableData();
      } else {
        ElMessage.error(res.msg || "审核失败");
      }
    })
    .catch(() => {
      ElMessage.error("审核失败");
    });
};
const handleAudit = (row) => {
  ElMessageBox.confirm("请选择审核结果", "付款申请审核", {
    confirmButtonText: "审核通过",
    cancelButtonText: "审核不通过",
    distinguishCancelAndClose: true,
    type: "warning",
  })
    .then(() => {
      submitAudit(row, 1);
    })
    .catch((action) => {
      if (action === "cancel") {
        submitAudit(row, 2);
      }
    });
};
const openPaymentDialog = (row) => {
  Object.assign(paymentForm, {
    paymentNumber: "",
    invoiceApplicationNo: row.invoiceApplicationNo ?? row.applyCode ?? "",
    supplierName: row.supplierName ?? "",
    supplierId: row.supplierId,
    accountPaymentApplicationId: row.id,
    paymentDate: new Date().toISOString().split("T")[0],
    paymentAmount: Number(row.paymentAmount ?? row.amount ?? 0),
    paymentMethod: row.paymentMethod ?? getDefaultPaymentMethod(),
    bankAccount: row.bankAccountNum ?? row.bankAccount ?? "",
    bankName: row.bankAccountName ?? row.bankName ?? "",
    remark: "",
  });
  paymentDialogVisible.value = true;
  nextTick(() => {
    paymentFormRef.value?.clearValidate();
  });
};
const submitPayment = () => {
  paymentFormRef.value?.validate((valid) => {
    if (!valid) return;
    paymentSubmitLoading.value = true;
    addAccountPurchasePayment({
      accountPaymentApplicationId: paymentForm.accountPaymentApplicationId,
      supplierId: paymentForm.supplierId,
      paymentDate: paymentForm.paymentDate,
      paymentMethod: paymentForm.paymentMethod,
      paymentAmount: paymentForm.paymentAmount,
      paymentNumber: paymentForm.paymentNumber || "",
      remark: paymentForm.remark || "",
    })
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("付款成功");
          paymentDialogVisible.value = false;
          getTableData();
        } else {
          ElMessage.error(res.msg || "付款失败");
        }
      })
      .catch(() => {
        ElMessage.error("付款失败");
      })
      .finally(() => {
        paymentSubmitLoading.value = false;
      });
  });
};
const handleDelete = (row) => {
  ElMessageBox.confirm(`确认删除申请单「${row.applyCode ?? row.invoiceApplicationNo}」吗?`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    deleteAccountPaymentApplication([row.id])
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("删除成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "删除失败");
        }
      })
      .catch(() => {
        ElMessage.error("删除失败");
      });
  });
};
const submitForm = () => {
  formRef.value?.validate((valid) => {
    if (!valid) return;
    submitLoading.value = true;
    const request = isEdit.value
      ? updateAccountPaymentApplication(buildSubmitPayload(true))
      : addAccountPaymentApplication(buildSubmitPayload(false));
    request
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success(isEdit.value ? "编辑成功" : "新增成功");
          closeDialog();
          pagination.currentPage = 1;
          getTableData();
        } else {
          ElMessage.error(res.msg || "保存失败");
        }
      })
      .catch(() => {
        ElMessage.error("保存失败");
      })
      .finally(() => {
        submitLoading.value = false;
      });
  });
};
const ensureInboundOptionsForSelected = () => {
  const ids = form.stockInRecordIds || [];
  ids.forEach((id) => {
    const exists = inboundBatchOptions.value.some((opt) => isSameInboundId(opt.value, id));
    if (exists) return;
    const fromList = inboundBatchList.value.find((row) => isSameInboundId(getInboundRowId(row), id));
    if (fromList) {
      const [option] = normalizeInboundBatchOptions([fromList]);
      if (option) inboundBatchOptions.value.push(option);
      return;
    }
    inboundBatchOptions.value.push({
      label: String(id),
      value: id,
      inboundAmount: 0,
    });
  });
};
const restoreInboundTableSelection = () => {
  nextTick(() => {
    const table = inboundTableRef.value;
    if (!table) return;
    table.clearSelection();
    const selectedIds = new Set((form.stockInRecordIds || []).map((id) => String(id)));
    inboundBatchList.value.forEach((row) => {
      const rowId = getInboundRowId(row);
      if (rowId !== undefined && rowId !== null && selectedIds.has(String(rowId))) {
        table.toggleRowSelection(row, true);
      }
    });
  });
};
const loadInboundBatches = (supplierId, keepSelected = false, syncAmount = true) => {
  if (!supplierId) {
    inboundBatchList.value = [];
    inboundBatchOptions.value = [];
    if (!keepSelected) {
      form.stockInRecordIds = [];
      form.inboundBatches = "";
      form.paymentAmount = 0;
    }
    return Promise.resolve();
  }
  inboundBatchLoading.value = true;
  return getInboundBatchesBySupplier({ supplierId })
    .then((res) => {
      if (res.code === 200) {
        const list = res.data?.records ?? res.data ?? [];
        inboundBatchList.value = Array.isArray(list) ? list : [];
        inboundBatchOptions.value = normalizeInboundBatchOptions(list);
      } else {
        inboundBatchList.value = [];
        inboundBatchOptions.value = [];
      }
    })
    .catch(() => {
      inboundBatchList.value = [];
      inboundBatchOptions.value = [];
    })
    .finally(() => {
      inboundBatchLoading.value = false;
      if (keepSelected) {
        ensureInboundOptionsForSelected();
        restoreInboundTableSelection();
        if (syncAmount && !isView.value) {
          syncPaymentAmount();
        }
      }
    });
};
const handleSupplierChange = (supplierId) => {
  form.stockInRecordIds = [];
  form.inboundBatches = "";
  form.paymentAmount = 0;
  loadInboundBatches(supplierId);
};
const handleInboundInputClick = () => {
  if (isEdit.value || isView.value) return;
  openInboundSelectDialog();
};
const openInboundSelectDialog = () => {
  if (!form.supplierId || isEdit.value || isView.value) return;
  inboundSelectVisible.value = true;
  loadInboundBatches(form.supplierId, true, false).then(() => {
    restoreInboundTableSelection();
  });
};
const handleInboundDialogSelectionChange = (selection) => {
  dialogInboundSelection.value = selection;
};
const confirmInboundSelection = () => {
  if (dialogInboundSelection.value.length === 0) {
    ElMessage.warning("请至少选择一条入库单");
    return;
  }
  form.stockInRecordIds = dialogInboundSelection.value
    .map((row) => getInboundRowId(row))
    .filter((id) => id !== undefined && id !== null);
  form.inboundBatches = dialogInboundSelection.value
    .map((row) => row.inboundBatches ?? row.batchNo ?? "")
    .filter(Boolean)
    .join("、");
  dialogInboundSelection.value.forEach((row) => {
    const [option] = normalizeInboundBatchOptions([row]);
    if (option && !inboundBatchOptions.value.some((opt) => isSameInboundId(opt.value, option.value))) {
      inboundBatchOptions.value.push(option);
    }
  });
  inboundSelectVisible.value = false;
  syncPaymentAmount();
  formRef.value?.validateField("stockInRecordIds");
};
const handleInboundDialogClosed = () => {
  dialogInboundSelection.value = [];
};
onMounted(() => {
  getSupplierList();
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
.text-danger {
  color: #f56c6c;
  font-weight: bold;
}
.inbound-batch-input :deep(.el-input__wrapper) {
  cursor: pointer;
}
</style>
src/views/financialManagement/payable/purchaseIn.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,212 @@
<template>
  <!-- é‡‡è´­å…¥åº“ -->
  <div class="app-container">
    <el-form :model="filters"
             :inline="true">
      <el-form-item label="入库单号:">
        <el-input v-model="filters.inboundBatches"
                  placeholder="请输入入库单号"
                  clearable
                  style="width: 200px;" />
      </el-form-item>
      <el-form-item label="供应商:">
        <el-select v-model="filters.supplierId"
                   placeholder="请选择供应商"
                   clearable
                   filterable
                   style="width: 200px;">
          <el-option v-for="item in supplierList"
                     :key="item.id"
                     :label="item.supplierName"
                     :value="item.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="入库日期:">
        <el-date-picker v-model="filters.dateRange"
                        value-format="YYYY-MM-DD"
                        format="YYYY-MM-DD"
                        type="daterange"
                        range-separator="至"
                        start-placeholder="开始日期"
                        end-placeholder="结束日期"
                        clearable />
      </el-form-item>
      <el-form-item>
        <el-button type="primary"
                   @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button @click="handleOut"
                     icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable rowKey="id"
                :column="columns"
                :tableData="dataList"
                :tableLoading="tableLoading"
                :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
                @pagination="changePage">
        <template #inboundDate="{ row }">
          {{ row.inboundDate ?? row.InboundDate ?? "" }}
        </template>
      </PIMTable>
    </div>
  </div>
</template>
<script setup>
  import { ref, reactive, onMounted, getCurrentInstance } from "vue";
  import { ElMessage } from "element-plus";
  import { listPageAccountPurchase } from "@/api/financialManagement/accountPurchase";
  import { listSupplier } from "@/api/basicData/supplierManageFile.js";
  defineOptions({
    name: "采购入库",
  });
  const { proxy } = getCurrentInstance();
  const filters = reactive({
    inboundBatches: "",
    supplierId: "",
    dateRange: [],
  });
  const pagination = reactive({
    currentPage: 1,
    pageSize: 10,
    total: 0,
  });
  const columns = [
    { label: "入库单号", prop: "inboundBatches", minWidth: "150" },
    { label: "供应商", prop: "supplierName", minWidth: "180" },
    {
      label: "入库日期",
      prop: "inboundDate",
      minWidth: "170",
      dataType: "slot",
      slot: "inboundDate",
    },
    { label: "产品名称", prop: "productName", minWidth: "140" },
    { label: "规格型号", prop: "specificationModel", minWidth: "140" },
    {
      label: "金额",
      prop: "inboundAmount",
      minWidth: "120",
      align: "right",
      formatData: val =>
        val === null || val === undefined || val === ""
          ? ""
          : Number(val).toLocaleString("zh-CN", {
              minimumFractionDigits: 2,
              maximumFractionDigits: 2,
            }),
    },
    { label: "采购订单号", prop: "purchaseContractNumber", minWidth: "150" },
  ];
  const dataList = ref([]);
  const tableLoading = ref(false);
  const supplierList = ref([]);
  const buildFilterParams = () => {
    const params = {};
    if (filters.inboundBatches) {
      params.inboundBatches = filters.inboundBatches;
    }
    if (filters.supplierId) {
      params.supplierId = filters.supplierId;
    }
    if (filters.dateRange?.length === 2) {
      params.startDate = filters.dateRange[0];
      params.endDate = filters.dateRange[1];
    }
    return params;
  };
  const getSupplierList = () => {
    listSupplier({ current: -1, size: -1, isWhite: 0 }).then(res => {
      if (res.code === 200) {
        supplierList.value = res.data?.records ?? [];
      }
    });
  };
  const onSearch = () => {
    pagination.currentPage = 1;
    getTableData();
  };
  const getTableData = () => {
    tableLoading.value = true;
    listPageAccountPurchase({
      ...buildFilterParams(),
      current: pagination.currentPage,
      size: pagination.pageSize,
    })
      .then(res => {
        const ok = res.code === 200 || res.code === 0;
        if (ok && res.data) {
          pagination.total = res.data.total ?? 0;
          dataList.value = res.data.records ?? [];
        } else {
          ElMessage.error(res.msg || "查询失败");
          dataList.value = [];
          pagination.total = 0;
        }
      })
      .catch(() => {
        dataList.value = [];
        pagination.total = 0;
        ElMessage.error("查询失败");
      })
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const resetFilters = () => {
    filters.inboundBatches = "";
    filters.supplierId = "";
    filters.dateRange = [];
    pagination.currentPage = 1;
    getTableData();
  };
  const changePage = ({ page, limit }) => {
    pagination.currentPage = page;
    pagination.pageSize = limit;
    getTableData();
  };
  const handleOut = () => {
    proxy.download(
      "/accountPurchase/exportAccountPurchaseInbound",
      buildFilterParams(),
      `采购入库_${Date.now()}.xlsx`
    );
  };
  onMounted(() => {
    getSupplierList();
    getTableData();
  });
</script>
<style lang="scss" scoped>
  .actions {
    display: flex;
    justify-content: space-between;
    margin-bottom: 15px;
  }
</style>
src/views/financialManagement/payable/purchaseReturn.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,198 @@
<template>
  <!-- é‡‡è´­é€€è´§ -->
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="退货单号:">
        <el-input v-model="filters.returnNo" placeholder="请输入退货单号" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="供应商:">
        <el-select v-model="filters.supplierId" placeholder="请选择供应商" clearable filterable style="width: 200px;">
          <el-option
            v-for="item in supplierList"
            :key="item.id"
            :label="item.supplierName"
            :value="item.id"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="退货日期:">
        <el-date-picker
          v-model="filters.dateRange"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          clearable
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button @click="handleOut" icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      />
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from "vue";
import { ElMessage } from "element-plus";
import { listPageAccountPurchaseReturn } from "@/api/financialManagement/accountPurchase";
import { listSupplier } from "@/api/basicData/supplierManageFile.js";
defineOptions({
  name: "采购退货",
});
const { proxy } = getCurrentInstance();
const filters = reactive({
  returnNo: "",
  supplierId: "",
  dateRange: [],
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "退货单号", prop: "returnNo", minWidth: "150" },
  { label: "供应商", prop: "supplierName", minWidth: "180" },
  { label: "关联入库单号", prop: "inboundBatches", minWidth: "150" },
  { label: "退货日期", prop: "preparedAt", minWidth: "170" },
  {
    label: "退款总额",
    prop: "totalAmount",
    minWidth: "150",
    align: "right",
    formatData: (val) =>
      val === null || val === undefined || val === ""
        ? ""
        : Number(val).toLocaleString("zh-CN", {
            minimumFractionDigits: 2,
            maximumFractionDigits: 2,
          }),
  },
  { label: "退货方式", prop: "returnType", minWidth: "150" },
  { label: "采购订单号", prop: "purchaseContractNumber", minWidth: "150" },
];
const dataList = ref([]);
const tableLoading = ref(false);
const supplierList = ref([]);
const buildFilterParams = () => {
  const params = {};
  if (filters.returnNo) {
    params.returnNo = filters.returnNo;
  }
  if (filters.supplierId) {
    params.supplierId = filters.supplierId;
  }
  if (filters.dateRange?.length === 2) {
    params.startDate = filters.dateRange[0];
    params.endDate = filters.dateRange[1];
  }
  return params;
};
const getSupplierList = () => {
  listSupplier({ current: -1, size: -1, isWhite: 0 }).then((res) => {
    if (res.code === 200) {
      supplierList.value = res.data?.records ?? [];
    }
  });
};
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountPurchaseReturn({
    ...buildFilterParams(),
    current: pagination.currentPage,
    size: pagination.pageSize,
  })
    .then((res) => {
      const ok = res.code === 200 || res.code === 0;
      if (ok && res.data) {
        pagination.total = res.data.total ?? 0;
        dataList.value = res.data.records ?? [];
      } else {
        ElMessage.error(res.msg || "查询失败");
        dataList.value = [];
        pagination.total = 0;
      }
    })
    .catch(() => {
      dataList.value = [];
      pagination.total = 0;
      ElMessage.error("查询失败");
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const resetFilters = () => {
  filters.returnNo = "";
  filters.supplierId = "";
  filters.dateRange = [];
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ page, limit }) => {
  pagination.currentPage = page;
  pagination.pageSize = limit;
  getTableData();
};
const handleOut = () => {
  proxy.download(
    "/accountPurchase/exportAccountPurchaseReturn",
    buildFilterParams(),
    `采购退货_${Date.now()}.xlsx`
  );
};
onMounted(() => {
  getSupplierList();
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
</style>
src/views/financialManagement/payable/reconciliation.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,766 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="供应商:">
        <el-select v-model="filters.supplierId" placeholder="请选择供应商" clearable filterable style="width: 200px;">
          <el-option
            v-for="item in supplierList"
            :key="item.id"
            :label="item.supplierName"
            :value="item.id"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="对账期间:">
        <el-date-picker v-model="filters.startMonth" type="month" placeholder="开始月份" value-format="YYYY-MM" style="width: 140px;" />
        <span style="margin: 0 10px;">至</span>
        <el-date-picker v-model="filters.endMonth" type="month" placeholder="结束月份" value-format="YYYY-MM" style="width: 140px;" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div>
          <el-button type="primary" @click="generateStatement" icon="Document">生成对账单</el-button>
        </div>
        <div>
          <el-button @click="handleOut" icon="Download">导出对账单</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      >
        <template #openingBalance="{ row }">
          <span :class="row.openingBalance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(row.openingBalance) }}</span>
        </template>
        <template #currentPlan="{ row }">
          <span class="text-danger">Â¥{{ formatMoney(row.currentPlan) }}</span>
        </template>
        <template #currentActually="{ row }">
          <span class="text-success">Â¥{{ formatMoney(row.currentActually) }}</span>
        </template>
        <template #closingBalance="{ row }">
          <span :class="row.closingBalance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(row.closingBalance) }}</span>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="viewDetail(row)">查看明细</el-button>
          <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog title="对账明细" v-model="detailDialogVisible" width="900px" @confirm="printDetail" @cancel="detailDialogVisible = false" operationType="detail">
      <div class="statement-header">
        <h3>{{ currentSupplier }} åº”付对账单</h3>
        <p>对账期间: {{ currentPeriod }}</p>
      </div>
      <el-table :data="detailData" border style="width: 100%" v-loading="detailLoading">
        <el-table-column prop="date" label="日期" width="120" />
        <el-table-column prop="type" label="类型" width="100">
          <template #default="{ row }">
            <el-tag :type="row.type === '入库' ? 'success' : row.type === '退货' ? 'danger' : 'primary'">{{ row.type }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="code" label="单据编号" width="150" />
        <el-table-column prop="debit" label="借方(付款)" width="120">
          <template #default="{ row }">
            <span v-if="row.debit > 0" class="text-success">Â¥{{ formatMoney(row.debit) }}</span>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column prop="credit" label="贷方(应付)" width="120">
          <template #default="{ row }">
            <span v-if="row.credit > 0" class="text-danger">Â¥{{ formatMoney(row.credit) }}</span>
            <span v-else-if="row.credit < 0" class="text-success">Â¥{{ formatMoney(Math.abs(row.credit)) }}</span>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column prop="balance" label="余额" width="120">
          <template #default="{ row }">
            <span :class="row.balance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(row.balance) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="remark" label="备注" show-overflow-tooltip />
      </el-table>
      <template #footer>
        <el-button type="primary" @click="printDetail">打印</el-button>
        <el-button @click="detailDialogVisible = false">关闭</el-button>
      </template>
    </FormDialog>
    <FormDialog title="生成对账单" v-model="generateDialogVisible" width="1000px" @confirm="confirmGenerate" @cancel="generateDialogVisible = false">
      <el-form :model="generateForm" label-width="100px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="选择供应商" prop="supplierId">
              <el-select
                v-model="generateForm.supplierId"
                placeholder="请选择供应商"
                style="width: 100%;"
                filterable
                @change="onSupplierChange"
              >
                <el-option
                  v-for="item in supplierList"
                  :key="item.id"
                  :label="item.supplierName"
                  :value="item.id"
                />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="对账月份" prop="statementMonth">
              <el-date-picker
                v-model="generateForm.statementMonth"
                type="month"
                placeholder="选择月份"
                value-format="YYYY-MM"
                style="width: 100%;"
                @change="onStatementMonthChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <div v-if="statementDetailLoaded" class="purchase-section">
        <div v-if="purchaseData.length > 0" class="section-title">本月采购数据</div>
        <el-table
          v-if="purchaseData.length > 0"
          ref="purchaseTableRef"
          :data="purchaseData"
          border
          row-key="id"
          style="width: 100%; margin-bottom: 15px;"
          v-loading="purchaseLoading"
          @selection-change="handlePurchaseSelectionChange"
        >
          <el-table-column type="selection" width="55" align="center" />
          <el-table-column prop="occurrenceDate" label="日期" width="120" />
          <el-table-column prop="receiptNumber" label="单据编号" width="150" />
          <el-table-column prop="type" label="类型" width="100">
            <template #default="{ row }">
              <el-tag :type="getDetailTypeTagType(row.type)">{{ row.typeLabel }}</el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="amount" label="金额" width="120">
            <template #default="{ row }">
              <span :class="getDetailAmountClass(row.type)">Â¥{{ formatMoney(row.amount) }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="remark" label="备注" />
        </el-table>
        <el-empty v-else description="该供应商本月暂无明细数据" :image-size="80" />
        <div class="summary-row">
          <span>期初余额: <strong class="text-primary">Â¥{{ formatMoney(generateForm.openingBalance) }}</strong></span>
          <span>本期应付: <strong class="text-danger">Â¥{{ formatMoney(generateForm.currentPlan) }}</strong></span>
          <span>本期付款: <strong class="text-success">Â¥{{ formatMoney(generateForm.currentActually) }}</strong></span>
          <span>期末余额: <strong :class="displayClosingBalance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(displayClosingBalance) }}</strong></span>
        </div>
      </div>
      <div v-else-if="generateForm.supplierId && generateForm.statementMonth && !purchaseLoading" class="empty-tip">
        <el-empty description="该供应商本月暂无采购数据" />
      </div>
      <template #footer>
        <el-button type="primary" @click="confirmGenerate" :disabled="!canGenerate" :loading="submitLoading">确认生成</el-button>
        <el-button @click="generateDialogVisible = false">取消</el-button>
      </template>
    </FormDialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, nextTick, getCurrentInstance } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { getOptions } from "@/api/procurementManagement/procurementLedger.js";
import {
  getAccountStatementDetailsByMonth,
  addAccountStatement,
  listPageAccountStatement,
  deleteAccountStatement,
} from "@/api/financialManagement/accountStatement.js";
const ACCOUNT_TYPE_PAYABLE = 2;
const { proxy } = getCurrentInstance();
defineOptions({
  name: "应付对账",
});
const filters = reactive({
  supplierId: "",
  startMonth: "",
  endMonth: "",
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "对账单号", prop: "statementNumber", width: "150" },
  { label: "供应商", prop: "supplierName", width: "180" },
  { label: "对账期间", prop: "statementMonth", width: "150" },
  { label: "期初余额", prop: "openingBalance", dataType: "slot", slot: "openingBalance" },
  { label: "本期应付", prop: "currentPlan", dataType: "slot", slot: "currentPlan" },
  { label: "本期付款", prop: "currentActually", dataType: "slot", slot: "currentActually" },
  { label: "期末余额", prop: "closingBalance", dataType: "slot", slot: "closingBalance" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "200", fixed: "right" },
];
const dataList = ref([]);
const tableLoading = ref(false);
const submitLoading = ref(false);
const detailDialogVisible = ref(false);
const currentSupplier = ref("");
const currentPeriod = ref("");
const detailData = ref([]);
const detailLoading = ref(false);
const generateDialogVisible = ref(false);
const purchaseLoading = ref(false);
const statementDetailLoaded = ref(false);
const purchaseData = ref([]);
const selectedPurchases = ref([]);
const purchaseTableRef = ref(null);
const supplierList = ref([]);
/** æ˜Žç»† type:1出库 2入库 3收款 4付款 5退货 */
const STATEMENT_DETAIL_TYPE_MAP = {
  1: "出库",
  2: "入库",
  3: "收款",
  4: "付款",
  5: "退货",
};
const calculateEndBalance = (openingBalance, currentPlan, currentActually) => {
  return openingBalance + currentPlan - currentActually;
};
const getDetailTypeLabel = (type) => STATEMENT_DETAIL_TYPE_MAP[Number(type)] ?? "";
const getDetailTypeTagType = (type) => {
  const t = Number(type);
  if (t === 2) return "success";
  if (t === 4) return "primary";
  if (t === 5) return "danger";
  return "info";
};
const getDetailAmountClass = (type) => {
  const t = Number(type);
  if (t === 2) return "text-danger";
  if (t === 4) return "text-success";
  return "text-danger";
};
const generateForm = reactive({
  supplierId: "",
  supplierName: "",
  statementMonth: "",
  openingBalance: 0,
  currentPlan: 0,
  currentActually: 0,
  closingBalance: 0,
});
const displayClosingBalance = computed(() =>
  calculateEndBalance(
    generateForm.openingBalance,
    generateForm.currentPlan,
    generateForm.currentActually
  )
);
const canGenerate = computed(
  () => generateForm.supplierId && generateForm.statementMonth && selectedPurchases.value.length > 0
);
const applyStatementSummary = (data) => {
  generateForm.openingBalance = Number(data.openingBalance ?? 0);
  generateForm.currentPlan = Number(data.currentPlan ?? 0);
  generateForm.currentActually = Number(data.currentActually ?? 0);
  generateForm.closingBalance = Number(
    data.closingBalance ??
      calculateEndBalance(
        generateForm.openingBalance,
        generateForm.currentPlan,
        generateForm.currentActually
      )
  );
};
const getSupplierList = () => {
  getOptions().then((res) => {
    if (res.code === 200) {
      supplierList.value = res.data ?? [];
    }
  });
};
const normalizePurchaseRows = (list) => {
  const rows = Array.isArray(list) ? list : [];
  return rows.map((item, index) => {
    const type = Number(item.type);
    return {
      id: item.id ?? `detail-${index}`,
      accountStatementId: item.accountStatementId,
      occurrenceDate: item.occurrenceDate ?? "",
      receiptNumber: item.receiptNumber ?? "",
      type,
      typeLabel: getDetailTypeLabel(type),
      amount: Math.abs(Number(item.amount ?? 0)),
      remark: item.remark ?? "",
    };
  });
};
const selectAllPurchaseRows = (keepApiSummary = false) => {
  nextTick(() => {
    const table = purchaseTableRef.value;
    if (!table) return;
    table.clearSelection();
    purchaseData.value.forEach((row) => table.toggleRowSelection(row, true));
    selectedPurchases.value = [...purchaseData.value];
    if (!keepApiSummary) {
      calculateSummary();
    }
  });
};
const isNumericId = (id) => id !== undefined && id !== null && id !== "" && /^\d+$/.test(String(id));
const buildFilterParams = (params = {}) => {
  const result = { ...params, accountType: ACCOUNT_TYPE_PAYABLE };
  if (filters.supplierId) {
    result.customerId = filters.supplierId;
  }
  if (filters.startMonth && filters.endMonth && filters.startMonth === filters.endMonth) {
    result.statementMonth = filters.startMonth;
  } else if (filters.startMonth) {
    result.startMonth = filters.startMonth;
  }
  if (filters.endMonth && filters.startMonth !== filters.endMonth) {
    result.endMonth = filters.endMonth;
  }
  return result;
};
const buildListParams = () =>
  buildFilterParams({
    current: pagination.currentPage,
    size: pagination.pageSize,
  });
const buildExportParams = () => buildFilterParams({});
const buildDetailSubmitItem = (row) => {
  const item = {
    occurrenceDate: row.occurrenceDate,
    receiptNumber: row.receiptNumber,
    type: row.type,
    amount: row.amount,
    remark: row.remark ?? "",
  };
  if (isNumericId(row.id)) {
    item.id = Number(row.id);
  }
  if (row.accountStatementId) {
    item.accountStatementId = row.accountStatementId;
  }
  return item;
};
const buildAddPayload = () => ({
  customerId: generateForm.supplierId,
  customerName: generateForm.supplierName,
  statementMonth: generateForm.statementMonth,
  accountType: ACCOUNT_TYPE_PAYABLE,
  statementNumber: "",
  openingBalance: generateForm.openingBalance,
  currentPlan: generateForm.currentPlan,
  currentActually: generateForm.currentActually,
  closingBalance: generateForm.closingBalance,
  accountStatementDetails: selectedPurchases.value.map(buildDetailSubmitItem),
});
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountStatement(buildListParams())
    .then((res) => {
      const ok = res.code === 200 || res.code === 0;
      if (ok && res.data) {
        pagination.total = res.data.total ?? 0;
        dataList.value = (res.data.records ?? []).map((row) => ({
          ...row,
          supplierName: row.supplierName ?? row.customerName,
        }));
      } else {
        ElMessage.error(res.msg || "查询失败");
        dataList.value = [];
        pagination.total = 0;
      }
    })
    .catch(() => {
      dataList.value = [];
      pagination.total = 0;
      ElMessage.error("查询失败");
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
const resetFilters = () => {
  filters.supplierId = "";
  filters.startMonth = "";
  filters.endMonth = "";
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ page, limit }) => {
  pagination.currentPage = page;
  pagination.pageSize = limit;
  getTableData();
};
const generateStatement = () => {
  generateForm.supplierId = "";
  generateForm.supplierName = "";
  generateForm.statementMonth = "";
  generateForm.openingBalance = 0;
  generateForm.currentPlan = 0;
  generateForm.currentActually = 0;
  generateForm.closingBalance = 0;
  statementDetailLoaded.value = false;
  purchaseData.value = [];
  selectedPurchases.value = [];
  generateDialogVisible.value = true;
};
const onSupplierChange = (supplierId) => {
  const supplier = supplierList.value.find((item) => item.id === supplierId);
  generateForm.supplierName = supplier?.supplierName ?? "";
  loadPurchaseData();
};
const onStatementMonthChange = () => {
  loadPurchaseData();
};
const loadPurchaseData = () => {
  if (!generateForm.supplierId || !generateForm.statementMonth) {
    purchaseData.value = [];
    selectedPurchases.value = [];
    statementDetailLoaded.value = false;
    generateForm.openingBalance = 0;
    generateForm.currentPlan = 0;
    generateForm.currentActually = 0;
    generateForm.closingBalance = 0;
    return;
  }
  purchaseLoading.value = true;
  selectedPurchases.value = [];
  statementDetailLoaded.value = false;
  getAccountStatementDetailsByMonth({
    accountType: ACCOUNT_TYPE_PAYABLE,
    customerId: generateForm.supplierId,
    statementMonth: generateForm.statementMonth,
  })
    .then((res) => {
      if (res.code === 200) {
        const data = res.data ?? {};
        const details = data.accountStatementDetails;
        const list = Array.isArray(details) ? details : [];
        purchaseData.value = normalizePurchaseRows(list);
        applyStatementSummary(data);
        statementDetailLoaded.value = true;
        if (purchaseData.value.length > 0) {
          selectAllPurchaseRows(true);
        }
      } else {
        purchaseData.value = [];
        statementDetailLoaded.value = false;
        ElMessage.error(res.msg || "查询对账明细失败");
      }
    })
    .catch(() => {
      purchaseData.value = [];
      statementDetailLoaded.value = false;
      ElMessage.error("查询对账明细失败");
    })
    .finally(() => {
      purchaseLoading.value = false;
    });
};
const calculateSummary = () => {
  let payable = 0;
  let payment = 0;
  selectedPurchases.value.forEach((item) => {
    if (item.type === 2) {
      payable += item.amount;
    } else if (item.type === 5) {
      payable -= item.amount;
    } else if (item.type === 4) {
      payment += item.amount;
    }
  });
  generateForm.currentPlan = payable;
  generateForm.currentActually = payment;
  generateForm.closingBalance = calculateEndBalance(
    generateForm.openingBalance,
    generateForm.currentPlan,
    generateForm.currentActually
  );
};
const handlePurchaseSelectionChange = (selection) => {
  selectedPurchases.value = selection;
  calculateSummary();
};
const confirmGenerate = () => {
  if (!canGenerate.value) return;
  submitLoading.value = true;
  addAccountStatement(buildAddPayload())
    .then((res) => {
      if (res.code === 200) {
        generateDialogVisible.value = false;
        ElMessage.success("对账单生成成功");
        pagination.currentPage = 1;
        getTableData();
      } else {
        ElMessage.error(res.msg || "生成失败");
      }
    })
    .catch(() => {
      ElMessage.error("生成失败");
    })
    .finally(() => {
      submitLoading.value = false;
    });
};
const handleDelete = (row) => {
  ElMessageBox.confirm(`确认删除对账单「${row.statementNumber || row.id}」吗?`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    deleteAccountStatement([row.id])
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("删除成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "删除失败");
        }
      })
      .catch(() => {
        ElMessage.error("删除失败");
      });
  });
};
const buildDetailTableFromApi = (data, statementMonth) => {
  const details = Array.isArray(data.accountStatementDetails) ? data.accountStatementDetails : [];
  let runningBalance = Number(data.openingBalance ?? 0);
  const rows = [
    {
      date: statementMonth ?? "",
      type: "期初",
      code: "-",
      debit: 0,
      credit: 0,
      balance: runningBalance,
      remark: "期初余额",
    },
  ];
  details.forEach((item) => {
    const amount = Math.abs(Number(item.amount ?? 0));
    const type = Number(item.type);
    let debit = 0;
    let credit = 0;
    if (type === 2) {
      credit = amount;
      runningBalance += amount;
    } else if (type === 4) {
      debit = amount;
      runningBalance -= amount;
    } else if (type === 5) {
      credit = -amount;
      runningBalance -= amount;
    }
    rows.push({
      date: item.occurrenceDate ?? "",
      type: getDetailTypeLabel(type),
      code: item.receiptNumber ?? "",
      debit,
      credit,
      balance: runningBalance,
      remark: item.remark ?? "",
    });
  });
  return rows;
};
const viewDetail = (row) => {
  const partnerId = row.customerId ?? row.supplierId;
  if (!partnerId || !row.statementMonth) {
    ElMessage.warning("缺少供应商或对账月份,无法查询明细");
    return;
  }
  currentSupplier.value = row.supplierName ?? row.customerName ?? "";
  currentPeriod.value = row.statementMonth ?? "";
  detailData.value = [];
  detailDialogVisible.value = true;
  detailLoading.value = true;
  getAccountStatementDetailsByMonth({
    accountType: ACCOUNT_TYPE_PAYABLE,
    customerId: partnerId,
    statementMonth: row.statementMonth,
  })
    .then((res) => {
      if (res.code === 200) {
        detailData.value = buildDetailTableFromApi(res.data ?? {}, row.statementMonth);
      } else {
        ElMessage.error(res.msg || "查询明细失败");
        detailDialogVisible.value = false;
      }
    })
    .catch(() => {
      ElMessage.error("查询明细失败");
      detailDialogVisible.value = false;
    })
    .finally(() => {
      detailLoading.value = false;
    });
};
const printDetail = () => {
  ElMessage.info("打印明细");
};
const handleOut = () => {
  proxy.download(
    "/accountStatement/exportAccountStatement",
    buildExportParams(),
    `应付对账单_${Date.now()}.xlsx`
  );
};
onMounted(() => {
  getSupplierList();
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
.text-success {
  color: #67c23a;
}
.text-danger {
  color: #f56c6c;
}
.text-primary {
  color: #409eff;
}
.statement-header {
  text-align: center;
  margin-bottom: 20px;
  h3 {
    margin: 0 0 10px 0;
  }
  p {
    color: #909399;
    margin: 0;
  }
}
.purchase-section {
  margin-top: 20px;
  .section-title {
    font-size: 16px;
    font-weight: bold;
    margin-bottom: 15px;
    padding-left: 10px;
    border-left: 4px solid #409eff;
  }
}
.summary-row {
  display: flex;
  justify-content: space-around;
  padding: 15px;
  background-color: #f5f7fa;
  border-radius: 4px;
  margin-top: 15px;
  span {
    font-size: 14px;
    strong {
      font-size: 16px;
      margin-left: 5px;
    }
  }
}
.empty-tip {
  margin-top: 30px;
}
</style>
src/views/financialManagement/receivable/invoiceApply.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,902 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="申请单号:">
        <el-input v-model="filters.applyCode" placeholder="请输入申请单号" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="客户:">
        <el-select v-model="filters.customerId" placeholder="请选择客户" clearable style="width: 200px;">
          <el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="审核状态:">
        <el-select v-model="filters.status" placeholder="请选择审核状态" clearable style="width: 150px;">
          <el-option label="待审核" :value="0" />
          <el-option label="审核通过" :value="1" />
          <el-option label="审核不通过" :value="2" />
        </el-select>
      </el-form-item>
      <el-form-item label="申请日期:">
        <el-date-picker
          v-model="filters.dateRange"
          type="daterange"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          clearable
          style="width: 240px;"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button type="primary" @click="add" icon="Plus">新增申请</el-button>
          <el-button type="success" @click="handleExport" icon="Download">导出开票申请</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        isSelection
        v-loading="tableLoading"
        :column="columns"
        :tableData="dataList"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @selection-change="handleSelectionChange"
        @pagination="changePage"
      >
        <template #amount="{ row }">
          <span class="text-primary">Â¥{{ formatMoney(row.amount) }}</span>
        </template>
        <template #taxRate="{ row }">
          <span>{{ row.taxRate }}%</span>
        </template>
        <template #status="{ row }">
          <el-tag :type="getStatusType(row.status)" effect="light" round>
            {{ getStatusLabel(row.status) }}
          </el-tag>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="primary" link @click="edit(row)" v-if="isPendingStatus(row.status)">编辑</el-button>
          <el-button type="danger" link @click="handleDelete(row)" v-if="isPendingStatus(row.status)">删除</el-button>
          <el-button type="success" link @click="handleAudit(row)" v-if="isPendingStatus(row.status)">审核</el-button>
          <el-button type="primary" link @click="openFileDialog(row)" v-if="isApprovedStatus(row.status)">附件</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog
      :title="dialogTitle"
      v-model="dialogVisible"
      width="800px"
      :operation-type="isView ? 'detail' : ''"
      @confirm="submitForm"
      @cancel="closeDialog"
    >
      <el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
        <el-row v-if="isView" :gutter="20">
          <el-col :span="12">
            <el-form-item label="审核状态">
              <el-tag :type="getStatusType(form.status)" effect="light" round>
                {{ getStatusLabel(form.status) }}
              </el-tag>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="申请单号" prop="applyCode">
              <el-input v-model="form.applyCode" placeholder="系统自动生成" disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="客户" prop="customerId">
              <el-select
                v-model="form.customerId"
                placeholder="请选择客户"
                style="width: 100%;"
                :disabled="isEdit || isView"
                filterable
                @change="handleCustomerChange"
              >
                <el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="出库单号" prop="outboundBatchNos">
              <el-input
                :model-value="outboundBatchDisplayText"
                placeholder="请先选择客户"
                readonly
                :disabled="!form.customerId || isEdit || isView"
                class="outbound-batch-input"
                @click="handleOutboundInputClick"
              >
                <template v-if="!isEdit && !isView" #append>
                  <el-button
                    :disabled="!form.customerId"
                    :loading="outboundBatchLoading"
                    @click.stop="openOutboundSelectDialog"
                  >
                    é€‰æ‹©
                  </el-button>
                </template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="开票金额" prop="amount">
              <el-input-number
                v-model="form.amount"
                :min="0"
                :precision="2"
                :disabled="isView"
                style="width: 100%;"
                placeholder="根据所选出库单自动汇总,可修改"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="税率" prop="taxRate">
              <el-select v-model="form.taxRate" placeholder="请选择税率" style="width: 100%;" :disabled="isView">
                <el-option
                  v-for="dict in tax_rate"
                  :key="dict.value"
                  :label="dict.label"
                  :value="Number(dict.value)"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="发票类型" prop="invoiceType">
              <el-select v-model="form.invoiceType" placeholder="请选择发票类型" style="width: 100%;" :disabled="isView">
                <el-option label="增值税专用发票" value="增值税专用发票" />
                <el-option label="增值税普通发票" value="增值税普通发票" />
                <el-option label="电子发票" value="电子发票" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="申请日期" prop="applyDate">
              <el-date-picker
                v-model="form.applyDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
                style="width: 100%;"
                :disabled="isView"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="发票内容" prop="content">
          <el-input v-model="form.content" type="textarea" :rows="3" placeholder="请输入发票内容" :disabled="isView" />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" :disabled="isView" />
        </el-form-item>
      </el-form>
      <template v-if="!isView" #footer>
        <el-button type="primary" :loading="submitLoading" @click="submitForm">确定</el-button>
        <el-button @click="closeDialog">取消</el-button>
      </template>
    </FormDialog>
    <el-dialog
      v-model="outboundSelectVisible"
      title="选择出库单"
      width="1200px"
      append-to-body
      destroy-on-close
      :close-on-click-modal="false"
      @closed="handleOutboundDialogClosed"
    >
      <el-table
        ref="outboundTableRef"
        v-loading="outboundBatchLoading"
        :data="outboundBatchList"
        row-key="id"
        border
        stripe
        max-height="480"
        @selection-change="handleOutboundDialogSelectionChange"
      >
        <el-table-column type="selection" width="55" align="center" />
        <el-table-column prop="outboundBatches" label="出库单号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="customerName" label="客户名称" min-width="120" show-overflow-tooltip />
        <el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
        <el-table-column prop="specificationModel" label="规格型号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="salesContractNo" label="销售合同号" min-width="140" show-overflow-tooltip />
        <el-table-column prop="shippingNo" label="发货单号" min-width="130" show-overflow-tooltip />
        <el-table-column prop="shippingDate" label="发货日期" width="110" align="center" />
        <el-table-column prop="outboundAmount" label="出库金额" width="110" align="right">
          <template #default="{ row }">Â¥{{ formatMoney(row.outboundAmount) }}</template>
        </el-table-column>
        <el-table-column prop="taxRate" label="税率" width="80" align="center">
          <template #default="{ row }">{{ row.taxRate }}%</template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button type="primary" @click="confirmOutboundSelection">确定</el-button>
        <el-button @click="outboundSelectVisible = false">取消</el-button>
      </template>
    </el-dialog>
    <FileList
      v-if="fileDialogVisible"
      v-model:visible="fileDialogVisible"
      record-type="account_invoice_application"
      :record-id="currentRecordId"
    />
  </div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, getCurrentInstance, defineAsyncComponent } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { listCustomer } from "@/api/basicData/customer.js";
import {
  getOutboundBatchesByCustomer,
  addAccountInvoiceApplication,
  listPageAccountInvoiceApplication,
  auditAccountInvoiceApplication,
  updateAccountInvoiceApplication,
  deleteAccountInvoiceApplication,
} from "@/api/financialManagement/invoiceApply.js";
const FileList = defineAsyncComponent(() => import("@/components/Dialog/FileList.vue"));
defineOptions({
  name: "开票申请",
});
const { proxy } = getCurrentInstance();
const { tax_rate } = proxy.useDict("tax_rate");
const filters = reactive({
  applyCode: "",
  customerId: "",
  status: "",
  dateRange: [],
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "申请单号", prop: "applyCode", width: "150" },
  { label: "客户名称", prop: "customerName", width: "180" },
  { label: "开票金额", prop: "amount", dataType: "slot", slot: "amount" },
  { label: "税率", prop: "taxRate", dataType: "slot", slot: "taxRate" },
  { label: "发票类型", prop: "invoiceType", width: "130" },
  { label: "申请日期", prop: "applyDate", width: "120" },
  { label: "审核状态", prop: "status", dataType: "slot", slot: "status", width: "110", align: "center" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "300", fixed: "right" },
];
const dataList = ref([]);
const tableLoading = ref(false);
const selectedRows = ref([]);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const isEdit = ref(false);
const isView = ref(false);
const currentId = ref(null);
const closeDialog = () => {
  dialogVisible.value = false;
  outboundSelectVisible.value = false;
  isView.value = false;
  isEdit.value = false;
};
const customerList = ref([]);
const outboundBatchList = ref([]);
const outboundBatchOptions = ref([]);
const outboundBatchLoading = ref(false);
const outboundSelectVisible = ref(false);
const outboundTableRef = ref(null);
const dialogOutboundSelection = ref([]);
const getCustomerList = () => {
  listCustomer({ current: -1, size: -1, type: 0 }).then((res) => {
    if (res.code === 200) {
      customerList.value = res.data?.records || [];
    }
  });
};
const normalizeOutboundBatchOptions = (data) => {
  const list = Array.isArray(data) ? data : [];
  return list.map((item, index) => {
    if (typeof item === "string" || typeof item === "number") {
      const text = String(item);
      return { label: text, value: text, outboundAmount: 0 };
    }
    const label =
      item.outboundBatches ??
      item.batchNo ??
      item.shippingNo ??
      item.outboundNo ??
      item.label ??
      `出库单${index + 1}`;
    const value = item.id ?? item.stockOutRecordId ?? item.stockOutRecordIds ?? label;
    const outboundAmount = Number(item.outboundAmount) || 0;
    const taxRate =
      item.taxRate !== undefined && item.taxRate !== null && item.taxRate !== ""
        ? Number(item.taxRate)
        : undefined;
    return { label: String(label), value, outboundAmount, taxRate };
  });
};
const isSameOutboundId = (a, b) => String(a) === String(b);
const getSelectedOutboundOptions = () => {
  const selected = form.outboundBatchNos || [];
  return outboundBatchOptions.value.filter((opt) =>
    selected.some((id) => isSameOutboundId(id, opt.value))
  );
};
/** æ ¡éªŒæ‰€é€‰å‡ºåº“单税率是否一致,一致则回填 form.taxRate */
const checkTaxRateConsistency = (showMessage = true) => {
  const selected = getSelectedOutboundOptions();
  if (selected.length === 0) return true;
  const withTaxRate = selected.filter(
    (opt) => opt.taxRate !== undefined && opt.taxRate !== null && !Number.isNaN(opt.taxRate)
  );
  if (withTaxRate.length === 0) return true;
  const uniqueRates = [...new Set(withTaxRate.map((opt) => Number(opt.taxRate)))];
  if (uniqueRates.length > 1) {
    if (showMessage) {
      const detail = withTaxRate.map((opt) => `${opt.label}(${opt.taxRate}%)`).join("、");
      ElMessage.error(`所选出库单税率不一致,无法开票:${detail}`);
    }
    return false;
  }
  form.taxRate = uniqueRates[0];
  return true;
};
/** æ ¹æ®æ‰€é€‰å‡ºåº“单汇总 outboundAmount ä½œä¸ºå¼€ç¥¨é‡‘额 */
const syncInvoiceAmount = () => {
  const selected = form.outboundBatchNos || [];
  const sum = outboundBatchOptions.value
    .filter((opt) => selected.some((id) => isSameOutboundId(id, opt.value)))
    .reduce((acc, opt) => acc + (Number(opt.outboundAmount) || 0), 0);
  form.amount = sum > 0 ? Number(sum.toFixed(2)) : 0;
};
const getOutboundRowId = (row) => row?.id ?? row?.stockOutRecordId;
const outboundBatchDisplayText = computed(() => {
  if (isEdit.value || isView.value) {
    return form.outboundBatches || "";
  }
  if (form.outboundBatches) return form.outboundBatches;
  const ids = form.outboundBatchNos || [];
  if (!ids.length) return "";
  return outboundBatchOptions.value
    .filter((opt) => ids.some((id) => isSameOutboundId(id, opt.value)))
    .map((opt) => opt.label)
    .join("、");
});
const handleOutboundInputClick = () => {
  if (isEdit.value || isView.value) return;
  openOutboundSelectDialog();
};
const restoreOutboundTableSelection = () => {
  nextTick(() => {
    const table = outboundTableRef.value;
    if (!table) return;
    table.clearSelection();
    const selectedIds = new Set((form.outboundBatchNos || []).map((id) => String(id)));
    outboundBatchList.value.forEach((row) => {
      const rowId = getOutboundRowId(row);
      if (rowId !== undefined && rowId !== null && selectedIds.has(String(rowId))) {
        table.toggleRowSelection(row, true);
      }
    });
  });
};
const openOutboundSelectDialog = () => {
  if (!form.customerId || isEdit.value || isView.value) return;
  outboundSelectVisible.value = true;
  loadOutboundBatches(form.customerId, true).then(() => {
    restoreOutboundTableSelection();
  });
};
const handleOutboundDialogSelectionChange = (selection) => {
  dialogOutboundSelection.value = selection;
};
const confirmOutboundSelection = () => {
  if (dialogOutboundSelection.value.length === 0) {
    ElMessage.warning("请至少选择一条出库单");
    return;
  }
  const prevIds = [...(form.outboundBatchNos || [])];
  const prevBatches = form.outboundBatches;
  form.outboundBatchNos = dialogOutboundSelection.value
    .map((row) => getOutboundRowId(row))
    .filter((id) => id !== undefined && id !== null);
  form.outboundBatches = dialogOutboundSelection.value
    .map((row) => row.outboundBatches ?? row.batchNo ?? row.shippingNo ?? "")
    .filter(Boolean)
    .join("、");
  if (!checkTaxRateConsistency()) {
    form.outboundBatchNos = prevIds;
    form.outboundBatches = prevBatches;
    return;
  }
  outboundSelectVisible.value = false;
  syncInvoiceAmount();
  formRef.value?.validateField("outboundBatchNos");
};
const handleOutboundDialogClosed = () => {
  dialogOutboundSelection.value = [];
};
const loadOutboundBatches = (customerId, keepSelected = false) => {
  if (!customerId) {
    outboundBatchList.value = [];
    outboundBatchOptions.value = [];
    if (!keepSelected) {
      form.outboundBatchNos = [];
      form.amount = 0;
    }
    return Promise.resolve();
  }
  outboundBatchLoading.value = true;
  return getOutboundBatchesByCustomer({ customerId })
    .then((res) => {
      if (res.code === 200) {
        const list = res.data?.records ?? res.data ?? [];
        outboundBatchList.value = Array.isArray(list) ? list : [];
        outboundBatchOptions.value = normalizeOutboundBatchOptions(list);
      } else {
        outboundBatchList.value = [];
        outboundBatchOptions.value = [];
      }
    })
    .catch(() => {
      outboundBatchList.value = [];
      outboundBatchOptions.value = [];
    })
    .finally(() => {
      outboundBatchLoading.value = false;
      if (keepSelected) {
        syncInvoiceAmount();
        checkTaxRateConsistency(false);
      }
    });
};
const handleCustomerChange = (customerId) => {
  form.outboundBatchNos = [];
  form.outboundBatches = "";
  form.amount = 0;
  loadOutboundBatches(customerId);
};
const form = reactive({
  applyCode: "",
  customerId: "",
  outboundBatchNos: [],
  outboundBatches: "",
  amount: 0,
  taxRate: 13,
  invoiceType: "增值税专用发票",
  applyDate: "",
  content: "",
  remark: "",
});
const rules = {
  customerId: [{ required: true, message: "请选择客户", trigger: "change" }],
  outboundBatchNos: [{ required: true, type: "array", min: 1, message: "请选择出库单号", trigger: "change" }],
  amount: [{ required: true, message: "请输入开票金额", trigger: "blur" }],
  taxRate: [{ required: true, message: "请选择税率", trigger: "change" }],
  invoiceType: [{ required: true, message: "请选择发票类型", trigger: "change" }],
  applyDate: [{ required: true, message: "请选择申请日期", trigger: "change" }],
};
/** å®¡æ ¸çŠ¶æ€ï¼š0待审核 1审核通过 2审核不通过 */
const STATUS_LABEL_MAP = {
  0: "待审核",
  1: "审核通过",
  2: "审核不通过",
};
const STATUS_TYPE_MAP = {
  0: "warning",
  1: "success",
  2: "danger",
};
const normalizeStatus = (status) => {
  if (status === undefined || status === null || status === "") return status;
  const num = Number(status);
  return Number.isNaN(num) ? status : num;
};
const isPendingStatus = (status) => normalizeStatus(status) === 0;
const isApprovedStatus = (status) => normalizeStatus(status) === 1;
const fileDialogVisible = ref(false);
const currentRecordId = ref(0);
const openFileDialog = (row) => {
  currentRecordId.value = row.id;
  fileDialogVisible.value = true;
};
const formatOutboundBatches = (value) => {
  if (value === undefined || value === null || value === "") return "";
  if (Array.isArray(value)) return value.filter(Boolean).join("、");
  return String(value)
    .split(/[,,]/)
    .map((s) => s.trim())
    .filter(Boolean)
    .join("、");
};
const normalizeTableRow = (row) => ({
  ...row,
  applyCode: row.invoiceApplicationNo ?? row.applyCode,
  amount: row.invoiceAmount ?? row.amount,
  content: row.invoiceContent ?? row.content,
  status: normalizeStatus(row.status ?? row.auditStatus),
  stockOutRecordIds: row.stockOutRecordIds ?? row.stockOutRecordId ?? "",
  outboundBatches: formatOutboundBatches(row.outboundBatches),
});
const appendFilterParams = (params) => {
  if (filters.applyCode) {
    params.invoiceApplicationNo = filters.applyCode;
  }
  if (filters.customerId) {
    params.customerId = filters.customerId;
  }
  if (filters.status !== "" && filters.status != null) {
    params.status = filters.status;
  }
  if (filters.dateRange?.length === 2) {
    params.startDate = filters.dateRange[0];
    params.endDate = filters.dateRange[1];
  }
  return params;
};
const buildListParams = () => {
  return appendFilterParams({
    current: pagination.currentPage,
    size: pagination.pageSize,
  });
};
const buildExportParams = () => {
  const params = appendFilterParams({});
  if (selectedRows.value.length > 0) {
    params.ids = selectedRows.value.map((row) => row.id).join(",");
  }
  return params;
};
const handleExport = () => {
  const params = buildExportParams();
  const filename =
    selectedRows.value.length > 0
      ? `开票申请_已选${selectedRows.value.length}条_${Date.now()}.xlsx`
      : `开票申请_${Date.now()}.xlsx`;
  proxy.download("/accountInvoiceApplication/exportAccountInvoiceApplication", params, filename);
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const getStatusLabel = (status) => {
  const num = normalizeStatus(status);
  if (num === 0 || num === 1 || num === 2) {
    return STATUS_LABEL_MAP[num];
  }
  return "-";
};
const getStatusType = (status) => {
  const num = normalizeStatus(status);
  if (num === 0 || num === 1 || num === 2) {
    return STATUS_TYPE_MAP[num];
  }
  return "info";
};
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountInvoiceApplication(buildListParams())
    .then((res) => {
      const ok = res.code === 200 || res.code === 0;
      if (ok && res.data) {
        pagination.total = res.data.total ?? 0;
        dataList.value = (res.data.records ?? []).map(normalizeTableRow);
      } else {
        ElMessage.error(res.msg || "查询失败");
        dataList.value = [];
        pagination.total = 0;
      }
    })
    .catch(() => {
      dataList.value = [];
      pagination.total = 0;
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const resetFilters = () => {
  filters.applyCode = "";
  filters.customerId = "";
  filters.status = "";
  filters.dateRange = [];
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ current, size }) => {
  pagination.currentPage = current;
  pagination.pageSize = size;
  getTableData();
};
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
const fillFormFromRow = (row) => {
  const outboundBatchNos = Array.isArray(row.outboundBatchNos)
    ? row.outboundBatchNos
    : parseStockOutRecordIds(row.stockOutRecordIds ?? row.stockOutRecordId);
  Object.assign(form, {
    ...row,
    applyCode: row.applyCode ?? row.invoiceApplicationNo ?? "",
    amount: Number(row.amount ?? row.invoiceAmount ?? 0),
    content: row.content ?? row.invoiceContent,
    status: normalizeStatus(row.status ?? row.auditStatus),
    outboundBatchNos,
    outboundBatches: formatOutboundBatches(row.outboundBatches),
  });
};
const add = () => {
  isEdit.value = false;
  isView.value = false;
  dialogTitle.value = "新增开票申请";
  Object.assign(form, {
    applyCode: "KP" + Date.now().toString().slice(-8),
    customerId: "",
    outboundBatchNos: [],
    outboundBatches: "",
    amount: 0,
    taxRate: 13,
    invoiceType: "增值税专用发票",
    applyDate: new Date().toISOString().split("T")[0],
    content: "",
    remark: "",
  });
  outboundBatchList.value = [];
  outboundBatchOptions.value = [];
  dialogVisible.value = true;
};
const parseStockOutRecordIds = (value) => {
  if (!value) return [];
  if (Array.isArray(value)) return value;
  return String(value)
    .split(/[,,]/)
    .map((s) => s.trim())
    .filter(Boolean)
    .map((s) => (/^\d+$/.test(s) ? Number(s) : s));
};
const buildSubmitPayload = (forUpdate = false) => {
  const payload = {
    customerId: form.customerId,
    stockOutRecordIds: (form.outboundBatchNos || []).join(","),
    invoiceApplicationNo: form.applyCode || "",
    invoiceType: form.invoiceType,
    applyDate: form.applyDate,
    invoiceContent: form.content,
    remark: form.remark || "",
    invoiceAmount: form.amount,
    taxRate: form.taxRate,
    status: 0,
  };
  if (forUpdate) {
    payload.id = currentId.value;
  }
  return payload;
};
const edit = (row) => {
  isEdit.value = true;
  isView.value = false;
  currentId.value = row.id;
  dialogTitle.value = "编辑开票申请";
  fillFormFromRow(row);
  dialogVisible.value = true;
  loadOutboundBatches(form.customerId, true);
};
const view = (row) => {
  isView.value = true;
  isEdit.value = false;
  dialogTitle.value = "查看开票申请";
  fillFormFromRow(row);
  dialogVisible.value = true;
};
const submitAudit = (row, status) => {
  auditAccountInvoiceApplication({ id: row.id, status })
    .then((res) => {
      if (res.code === 200) {
        ElMessage.success(status === 1 ? "审核通过" : "审核不通过");
        getTableData();
      } else {
        ElMessage.error(res.msg || "审批失败");
      }
    })
    .catch(() => {
      ElMessage.error("审批失败");
    });
};
const handleDelete = (row) => {
  ElMessageBox.confirm(`确认删除申请单「${row.applyCode ?? row.invoiceApplicationNo}」吗?`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    deleteAccountInvoiceApplication([row.id])
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("删除成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "删除失败");
        }
      })
      .catch(() => {
        ElMessage.error("删除失败");
      });
  });
};
const handleAudit = (row) => {
  ElMessageBox.confirm("请选择审批结果", "开票申请审核", {
    confirmButtonText: "审核通过",
    cancelButtonText: "审核不通过",
    distinguishCancelAndClose: true,
    type: "warning",
  })
    .then(() => {
      submitAudit(row, 1);
    })
    .catch((action) => {
      if (action === "cancel") {
        submitAudit(row, 2);
      }
    });
};
const handleInvoice = (row) => {
  ElMessageBox.confirm("确认已开具发票?", "提示", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "info",
  }).then(() => {
    ElMessage.success("开票完成");
    getTableData();
  });
};
const handleBatchApply = () => {
  ElMessage.success(`批量申请 ${selectedRows.value.length} æ¡è®°å½•`);
};
const submitLoading = ref(false);
const submitForm = () => {
  formRef.value.validate((valid) => {
    if (!valid) return;
    if (!checkTaxRateConsistency()) return;
    submitLoading.value = true;
    const request = isEdit.value
      ? updateAccountInvoiceApplication(buildSubmitPayload(true))
      : addAccountInvoiceApplication(buildSubmitPayload());
    request
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success(isEdit.value ? "修改成功" : "新增成功");
          closeDialog();
          getTableData();
        } else {
          ElMessage.error(res.msg || (isEdit.value ? "修改失败" : "新增失败"));
        }
      })
      .catch(() => {
        ElMessage.error(isEdit.value ? "修改失败" : "新增失败");
      })
      .finally(() => {
        submitLoading.value = false;
      });
  });
};
onMounted(() => {
  getCustomerList();
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
.text-primary {
  color: #409eff;
  font-weight: bold;
}
.outbound-batch-input:not(.is-disabled) {
  :deep(.el-input__wrapper) {
    cursor: pointer;
  }
}
</style>
src/views/financialManagement/receivable/outputInvoice.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,608 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="发票号码:">
        <el-input v-model="filters.invoiceNumber" placeholder="请输入发票号码" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="客户:">
        <el-select v-model="filters.customerId" placeholder="请选择客户" clearable style="width: 200px;">
          <el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="开票日期:">
        <el-date-picker
          v-model="filters.dateRange"
          type="daterange"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          clearable
          style="width: 240px;"
        />
      </el-form-item>
      <el-form-item label="状态:">
        <el-select v-model="filters.status" placeholder="请选择状态" clearable style="width: 150px;">
          <el-option label="正常" :value="0" />
          <el-option label="作废" :value="1" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <!-- <el-button type="primary" @click="add" icon="Plus">录入发票</el-button> -->
          <el-button type="success" @click="handleExport" icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        v-loading="tableLoading"
        :column="columns"
        :tableData="dataList"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      >
        <template #amount="{ row }">
          <span class="text-primary">Â¥{{ formatMoney(row.amount) }}</span>
        </template>
        <template #taxAmount="{ row }">
          <span class="text-danger">Â¥{{ formatMoney(row.taxAmount) }}</span>
        </template>
        <template #totalAmount="{ row }">
          <span class="text-success">Â¥{{ formatMoney(row.totalAmount) }}</span>
        </template>
        <template #status="{ row }">
          <el-tag :type="getStatusType(row.status)" effect="light" round>
            {{ getStatusLabel(row.status) }}
          </el-tag>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button
            type="primary"
            link
            @click="openFileDialog(row)"
            v-if="row.accountInvoiceApplicationId"
          >
            é™„ä»¶
          </el-button>
          <el-button type="warning" link @click="handleCancel(row)" v-if="isNormalStatus(row.status)">作废</el-button>
          <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog
      :title="dialogTitle"
      v-model="dialogVisible"
      width="800px"
      :operation-type="isView ? 'detail' : ''"
      @confirm="submitForm"
      @cancel="closeDialog"
    >
      <el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
        <el-row v-if="isView" :gutter="20">
          <el-col :span="12">
            <el-form-item label="状态">
              <el-tag :type="getStatusType(form.status)" effect="light" round>
                {{ getStatusLabel(form.status) }}
              </el-tag>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="发票号码" prop="invoiceNo">
              <el-input v-model="form.invoiceNo" placeholder="请输入发票号码" :disabled="isView" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="客户" prop="customerId">
              <el-select v-model="form.customerId" placeholder="请选择客户" style="width: 100%;" :disabled="isView">
                <el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="开票日期" prop="invoiceDate">
              <el-date-picker
                v-model="form.invoiceDate"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
                style="width: 100%;"
                :disabled="isView"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="发票类型" prop="invoiceType">
              <el-select
                v-model="form.invoiceType"
                placeholder="请选择发票类型"
                style="width: 100%;"
                :disabled="isView"
                @change="handleInvoiceTypeChange"
              >
                <el-option label="增值税专用发票" value="增值税专用发票" />
                <el-option label="增值税普通发票" value="增值税普通发票" />
                <el-option label="电子发票" value="电子发票" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="税率" prop="taxRate">
              <el-select
                v-model="form.taxRate"
                placeholder="请选择税率"
                style="width: 100%;"
                :disabled="isView"
                @change="calculateTax"
              >
                <el-option
                  v-for="dict in tax_rate"
                  :key="dict.value"
                  :label="dict.label"
                  :value="Number(dict.value)"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="8">
            <el-form-item label="金额(不含税)" prop="amount">
              <el-input-number
                v-model="form.amount"
                :min="0"
                :precision="2"
                style="width: 100%;"
                :disabled="isView"
                @change="calculateTax"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="税额">
              <el-input v-model="form.taxAmount" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="价税合计">
              <el-input v-model="form.totalAmount" disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="发票内容" prop="content">
          <el-input v-model="form.content" type="textarea" :rows="3" placeholder="请输入发票内容" :disabled="isView" />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" :disabled="isView" />
        </el-form-item>
      </el-form>
      <template v-if="!isView" #footer>
        <el-button type="primary" :loading="submitLoading" @click="submitForm">确定</el-button>
        <el-button @click="closeDialog">取消</el-button>
      </template>
    </FormDialog>
    <FileList
      v-if="fileDialogVisible"
      v-model:visible="fileDialogVisible"
      record-type="account_invoice_application"
      :record-id="currentRecordId"
      :editable="false"
    />
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance, defineAsyncComponent } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { listCustomer } from "@/api/basicData/customer.js";
import {
  addAccountSalesInvoice,
  listPageAccountSalesInvoice,
  cancelAccountSalesInvoice,
  deleteAccountSalesInvoice,
} from "@/api/financialManagement/accountSalesInvoice.js";
const FileList = defineAsyncComponent(() => import("@/components/Dialog/FileList.vue"));
defineOptions({
  name: "销项发票",
});
const { proxy } = getCurrentInstance();
const { tax_rate } = proxy.useDict("tax_rate");
const filters = reactive({
  invoiceNumber: "",
  customerId: "",
  dateRange: [],
  status: "",
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "发票号码", prop: "invoiceNo", width: "140" },
  { label: "客户名称", prop: "customerName", width: "180" },
  { label: "开票日期", prop: "invoiceDate", width: "120" },
  { label: "金额", prop: "amount", dataType: "slot", slot: "amount" },
  { label: "税额", prop: "taxAmount", dataType: "slot", slot: "taxAmount" },
  { label: "价税合计", prop: "totalAmount", dataType: "slot", slot: "totalAmount" },
  { label: "发票类型", prop: "invoiceType", width: "130" },
  { label: "状态", prop: "status", dataType: "slot", slot: "status", width: "90", align: "center" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "260", fixed: "right" },
];
const dataList = ref([]);
const tableLoading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const isView = ref(false);
const submitLoading = ref(false);
const customerList = ref([]);
const fileDialogVisible = ref(false);
const currentRecordId = ref(0);
const openFileDialog = (row) => {
  if (!row.accountInvoiceApplicationId) {
    ElMessage.warning("未关联开票申请,无法查看附件");
    return;
  }
  currentRecordId.value = row.accountInvoiceApplicationId;
  fileDialogVisible.value = true;
};
/** çŠ¶æ€ï¼š0正常 1作废 */
const STATUS_LABEL_MAP = { 0: "正常", 1: "作废" };
const STATUS_TYPE_MAP = { 0: "success", 1: "info" };
const normalizeStatus = (status) => {
  if (status === undefined || status === null || status === "") return 0;
  const num = Number(status);
  return Number.isNaN(num) ? 0 : num;
};
const isNormalStatus = (status) => normalizeStatus(status) === 0;
const getStatusLabel = (status) => {
  const num = normalizeStatus(status);
  return STATUS_LABEL_MAP[num] ?? "正常";
};
const getStatusType = (status) => {
  const num = normalizeStatus(status);
  return STATUS_TYPE_MAP[num] ?? "success";
};
const form = reactive({
  invoiceNo: "",
  customerId: "",
  invoiceDate: "",
  invoiceType: "增值税专用发票",
  taxRate: 13,
  amount: 0,
  taxAmount: 0,
  totalAmount: 0,
  content: "",
  remark: "",
  accountInvoiceApplicationId: undefined,
  storageAttachmentId: undefined,
});
const rules = {
  invoiceNo: [{ required: true, message: "请输入发票号码", trigger: "blur" }],
  customerId: [{ required: true, message: "请选择客户", trigger: "change" }],
  invoiceDate: [{ required: true, message: "请选择开票日期", trigger: "change" }],
  invoiceType: [{ required: true, message: "请选择发票类型", trigger: "change" }],
  taxRate: [{ required: true, message: "请选择税率", trigger: "change" }],
  amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const calculateTax = () => {
  form.taxAmount = Number((form.amount * form.taxRate / 100).toFixed(2));
  form.totalAmount = Number((form.amount + form.taxAmount).toFixed(2));
};
const handleInvoiceTypeChange = () => {
  calculateTax();
};
const normalizeTableRow = (row) => ({
  ...row,
  invoiceNo: row.invoiceNumber ?? row.invoiceNo,
  invoiceDate: row.issueDate ?? row.invoiceDate,
  amount: row.taxExclusivelPrice ?? row.amount,
  taxAmount: row.taxPrice ?? row.taxAmount,
  totalAmount: row.taxInclusivePrice ?? row.totalAmount,
  content: row.invoiceContent ?? row.content,
  status: normalizeStatus(row.status),
});
const fillFormFromRow = (row) => {
  Object.assign(form, {
    invoiceNo: row.invoiceNo ?? row.invoiceNumber ?? "",
    customerId: row.customerId,
    invoiceDate: row.invoiceDate ?? row.issueDate ?? "",
    invoiceType: row.invoiceType ?? "增值税专用发票",
    taxRate: row.taxRate ?? 13,
    amount: row.amount ?? row.taxExclusivelPrice ?? 0,
    taxAmount: row.taxAmount ?? row.taxPrice ?? 0,
    totalAmount: row.totalAmount ?? row.taxInclusivePrice ?? 0,
    content: row.content ?? row.invoiceContent ?? "",
    remark: row.remark ?? "",
    accountInvoiceApplicationId: row.accountInvoiceApplicationId,
    storageAttachmentId: row.storageAttachmentId,
    status: normalizeStatus(row.status),
  });
};
const buildCancelPayload = (row) => ({
  id: row.id,
  accountInvoiceApplicationId: row.accountInvoiceApplicationId,
  invoiceNumber: row.invoiceNumber ?? row.invoiceNo,
  taxRate: row.taxRate,
  invoiceType: row.invoiceType,
  issueDate: row.issueDate ?? row.invoiceDate,
  taxExclusivelPrice: row.taxExclusivelPrice ?? row.amount,
  taxPrice: row.taxPrice ?? row.taxAmount,
  taxInclusivePrice: row.taxInclusivePrice ?? row.totalAmount,
  remark: row.remark ?? "",
  invoiceContent: row.invoiceContent ?? row.content,
  customerId: row.customerId,
  storageAttachmentId: row.storageAttachmentId,
  status: 1,
});
const buildSubmitPayload = () => ({
  invoiceNumber: form.invoiceNo,
  customerId: form.customerId,
  issueDate: form.invoiceDate,
  invoiceType: form.invoiceType,
  taxRate: form.taxRate,
  taxExclusivelPrice: form.amount,
  taxPrice: form.taxAmount,
  taxInclusivePrice: form.totalAmount,
  invoiceContent: form.content,
  remark: form.remark || "",
  accountInvoiceApplicationId: form.accountInvoiceApplicationId,
  storageAttachmentId: form.storageAttachmentId,
});
const getCustomerList = () => {
  listCustomer({ current: -1, size: -1, type: 0 }).then((res) => {
    if (res.code === 200) {
      customerList.value = res.data?.records || [];
    }
  });
};
const appendFilterParams = (params) => {
  if (filters.invoiceNumber) {
    params.invoiceNumber = filters.invoiceNumber;
  }
  if (filters.customerId) {
    params.customerId = filters.customerId;
  }
  if (filters.dateRange?.length === 2) {
    params.startDate = filters.dateRange[0];
    params.endDate = filters.dateRange[1];
  }
  if (filters.status !== "" && filters.status != null) {
    params.status = filters.status;
  }
  return params;
};
const buildListParams = () =>
  appendFilterParams({
    current: pagination.currentPage,
    size: pagination.pageSize,
  });
const buildExportParams = () => appendFilterParams({});
const handleExport = () => {
  const params = buildExportParams();
  proxy.download("/accountSalesInvoice/exportAccountSalesInvoice", params, `销项发票_${Date.now()}.xlsx`);
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountSalesInvoice(buildListParams())
    .then((res) => {
      if (res.code === 200) {
        const records = res.data?.records ?? [];
        dataList.value = records.map(normalizeTableRow);
        pagination.total = res.data?.total ?? 0;
      } else {
        dataList.value = [];
        pagination.total = 0;
        ElMessage.error(res.msg || "查询失败");
      }
    })
    .catch(() => {
      dataList.value = [];
      pagination.total = 0;
      ElMessage.error("查询失败");
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
const resetFilters = () => {
  filters.invoiceNumber = "";
  filters.customerId = "";
  filters.dateRange = [];
  filters.status = "";
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ current, size }) => {
  pagination.currentPage = current;
  pagination.pageSize = size;
  getTableData();
};
const closeDialog = () => {
  dialogVisible.value = false;
  isView.value = false;
};
const add = () => {
  isView.value = false;
  dialogTitle.value = "录入发票";
  Object.assign(form, {
    invoiceNo: "",
    customerId: "",
    invoiceDate: new Date().toISOString().split("T")[0],
    invoiceType: "增值税专用发票",
    taxRate: 13,
    amount: 0,
    taxAmount: 0,
    totalAmount: 0,
    content: "",
    remark: "",
    accountInvoiceApplicationId: undefined,
    storageAttachmentId: undefined,
  });
  dialogVisible.value = true;
};
const view = (row) => {
  isView.value = true;
  dialogTitle.value = "查看发票";
  fillFormFromRow(row);
  dialogVisible.value = true;
};
const handleCancel = (row) => {
  ElMessageBox.confirm(`确认作废发票「${row.invoiceNo ?? row.invoiceNumber}」吗?`, "作废确认", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    cancelAccountSalesInvoice(buildCancelPayload(row))
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("作废成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "作废失败");
        }
      })
      .catch(() => {
        ElMessage.error("作废失败");
      });
  });
};
const handleDelete = (row) => {
  ElMessageBox.confirm(`确认删除发票「${row.invoiceNo ?? row.invoiceNumber}」吗?删除后不可恢复。`, "删除确认", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    deleteAccountSalesInvoice([row.id])
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("删除成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "删除失败");
        }
      })
      .catch(() => {
        ElMessage.error("删除失败");
      });
  });
};
const submitForm = () => {
  formRef.value.validate((valid) => {
    if (!valid) return;
    submitLoading.value = true;
    addAccountSalesInvoice(buildSubmitPayload())
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("录入成功");
          closeDialog();
          getTableData();
        } else {
          ElMessage.error(res.msg || "录入失败");
        }
      })
      .catch(() => {
        ElMessage.error("录入失败");
      })
      .finally(() => {
        submitLoading.value = false;
      });
  });
};
onMounted(() => {
  getCustomerList();
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
.text-primary {
  color: #409eff;
  font-weight: bold;
}
.text-danger {
  color: #f56c6c;
  font-weight: bold;
}
.text-success {
  color: #67c23a;
  font-weight: bold;
}
</style>
src/views/financialManagement/receivable/receipt.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,855 @@
<template>
  <div class="app-container">
    <el-form :model="filters"
             :inline="true">
      <el-form-item label="收款单号:">
        <el-input v-model="filters.collectionNumber"
                  placeholder="请输入收款单号"
                  clearable
                  style="width: 200px;" />
      </el-form-item>
      <el-form-item label="客户:">
        <el-select v-model="filters.customerId"
                   placeholder="请选择客户"
                   clearable
                   filterable
                   style="width: 200px;">
          <el-option v-for="item in customerList"
                     :key="item.id"
                     :label="item.customerName"
                     :value="item.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="收款方式:">
        <el-select v-model="filters.collectionMethod"
                   placeholder="请选择收款方式"
                   clearable
                   style="width: 150px;">
          <el-option v-for="item in payment_methods"
                     :key="item.value"
                     :label="item.label"
                     :value="item.value" />
        </el-select>
      </el-form-item>
      <el-form-item label="收款日期:">
        <el-date-picker v-model="filters.dateRange"
                        type="daterange"
                        value-format="YYYY-MM-DD"
                        format="YYYY-MM-DD"
                        range-separator="至"
                        start-placeholder="开始日期"
                        end-placeholder="结束日期"
                        clearable
                        style="width: 240px;" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary"
                   @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div>
          <el-statistic title="本页收款合计"
                        :value="totalReceiptAmount"
                        :precision="2"
                        prefix="Â¥" />
        </div>
        <div>
          <el-button type="primary"
                     @click="add"
                     icon="Plus">新增收款</el-button>
          <el-button type="success"
                     @click="handleExport"
                     icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable rowKey="id"
                v-loading="tableLoading"
                :column="columns"
                :tableData="dataList"
                :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
                @pagination="changePage">
        <template #amount="{ row }">
          <span class="text-success">Â¥{{ formatMoney(row.amount) }}</span>
        </template>
        <template #receiptMethod="{ row }">
          <span>{{ getReceiptMethodLabel(row.receiptMethod) }}</span>
        </template>
        <template #operation="{ row }">
          <el-button type="primary"
                     link
                     @click="view(row)">查看</el-button>
          <el-button :disabled="row.accountStatemen"
                     type="primary"
                     link
                     @click="edit(row)">编辑</el-button>
          <el-button :disabled="row.accountStatemen"
                     type="danger"
                     link
                     @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog :title="dialogTitle"
                v-model="dialogVisible"
                width="800px"
                :operation-type="isView ? 'detail' : ''"
                @confirm="submitForm"
                @cancel="closeDialog">
      <el-form :model="form"
               :rules="rules"
               ref="formRef"
               label-width="120px">
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="收款单号"
                          prop="receiptCode">
              <el-input v-model="form.receiptCode"
                        placeholder="系统自动生成"
                        disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="客户"
                          prop="customerId">
              <el-select v-model="form.customerId"
                         placeholder="请选择客户"
                         style="width: 100%;"
                         :disabled="isEdit || isView"
                         filterable
                         @change="handleCustomerChange">
                <el-option v-for="item in customerList"
                           :key="item.id"
                           :label="item.customerName"
                           :value="item.id" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="关联单据"
                          prop="stockOutRecordIds">
              <el-input :model-value="outboundBatchDisplayText"
                        placeholder="请先选择客户"
                        readonly
                        :disabled="!form.customerId || isEdit || isView"
                        class="outbound-batch-input"
                        @click="handleOutboundInputClick">
                <template v-if="!isEdit && !isView"
                          #append>
                  <el-button :disabled="!form.customerId"
                             :loading="outboundBatchLoading"
                             @click.stop="openOutboundSelectDialog">
                    é€‰æ‹©
                  </el-button>
                </template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="收款日期"
                          prop="receiptDate">
              <el-date-picker v-model="form.receiptDate"
                              type="date"
                              placeholder="选择日期"
                              value-format="YYYY-MM-DD"
                              style="width: 100%;"
                              :disabled="isView" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="收款金额"
                          prop="amount">
              <el-input-number v-model="form.amount"
                               :min="0"
                               :precision="2"
                               style="width: 100%;"
                               :disabled="isView"
                               placeholder="根据关联单据自动汇总,可修改" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="收款方式"
                          prop="receiptMethod">
              <el-select v-model="form.receiptMethod"
                         placeholder="请选择收款方式"
                         style="width: 100%;"
                         :disabled="isView">
                <el-option v-for="item in payment_methods"
                           :key="item.value"
                           :label="item.label"
                           :value="item.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="备注"
                      prop="remark">
          <el-input v-model="form.remark"
                    type="textarea"
                    :rows="3"
                    placeholder="请输入备注"
                    :disabled="isView" />
        </el-form-item>
      </el-form>
      <template v-if="!isView"
                #footer>
        <el-button type="primary"
                   :loading="submitLoading"
                   @click="submitForm">确定</el-button>
        <el-button @click="closeDialog">取消</el-button>
      </template>
    </FormDialog>
    <el-dialog v-model="outboundSelectVisible"
               title="选择关联单据"
               width="1200px"
               append-to-body
               destroy-on-close
               :close-on-click-modal="false"
               @closed="handleOutboundDialogClosed">
      <el-table ref="outboundTableRef"
                v-loading="outboundBatchLoading"
                :data="outboundBatchList"
                row-key="id"
                border
                stripe
                max-height="480"
                @selection-change="handleOutboundDialogSelectionChange">
        <el-table-column type="selection"
                         width="55"
                         align="center" />
        <el-table-column prop="outboundBatches"
                         label="出库单号"
                         min-width="140"
                         show-overflow-tooltip />
        <el-table-column prop="customerName"
                         label="客户名称"
                         min-width="120"
                         show-overflow-tooltip />
        <el-table-column prop="productName"
                         label="产品名称"
                         min-width="120"
                         show-overflow-tooltip />
        <el-table-column prop="specificationModel"
                         label="规格型号"
                         min-width="140"
                         show-overflow-tooltip />
        <el-table-column prop="salesContractNo"
                         label="销售合同号"
                         min-width="140"
                         show-overflow-tooltip />
        <el-table-column prop="shippingNo"
                         label="发货单号"
                         min-width="130"
                         show-overflow-tooltip />
        <el-table-column prop="shippingDate"
                         label="发货日期"
                         width="110"
                         align="center" />
        <el-table-column prop="outboundAmount"
                         label="出库金额"
                         width="110"
                         align="right">
          <template #default="{ row }">Â¥{{ formatMoney(row.outboundAmount) }}</template>
        </el-table-column>
        <el-table-column prop="taxRate"
                         label="税率"
                         width="80"
                         align="center">
          <template #default="{ row }">{{ row.taxRate }}%</template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button type="primary"
                   @click="confirmOutboundSelection">确定</el-button>
        <el-button @click="outboundSelectVisible = false">取消</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
  import {
    ref,
    reactive,
    computed,
    onMounted,
    nextTick,
    getCurrentInstance,
  } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import FormDialog from "@/components/Dialog/FormDialog.vue";
  import { listCustomer } from "@/api/basicData/customer.js";
  import {
    getOutboundBatchesByCustomer,
    addAccountSalesCollection,
    listPageAccountSalesCollection,
    updateAccountSalesCollection,
    deleteAccountSalesCollection,
  } from "@/api/financialManagement/accountSalesCollection.js";
  defineOptions({
    name: "收款单",
  });
  const { proxy } = getCurrentInstance();
  const { payment_methods } = proxy.useDict("payment_methods");
  const filters = reactive({
    collectionNumber: "",
    customerId: "",
    collectionMethod: "",
    dateRange: [],
  });
  const pagination = reactive({
    currentPage: 1,
    pageSize: 10,
    total: 0,
  });
  const columns = [
    { label: "收款单号", prop: "receiptCode", width: "150" },
    { label: "客户名称", prop: "customerName", width: "180" },
    { label: "收款日期", prop: "receiptDate", width: "120" },
    { label: "收款金额", prop: "amount", dataType: "slot", slot: "amount" },
    {
      label: "收款方式",
      prop: "receiptMethod",
      dataType: "slot",
      slot: "receiptMethod",
      width: "120",
    },
    { label: "备注", prop: "remark", showOverflowTooltip: true },
    {
      label: "操作",
      prop: "operation",
      dataType: "slot",
      slot: "operation",
      width: "200",
      fixed: "right",
    },
  ];
  const dataList = ref([]);
  const tableLoading = ref(false);
  const dialogVisible = ref(false);
  const dialogTitle = ref("");
  const formRef = ref(null);
  const isEdit = ref(false);
  const isView = ref(false);
  const currentId = ref(null);
  const submitLoading = ref(false);
  const customerList = ref([]);
  const outboundBatchList = ref([]);
  const outboundBatchOptions = ref([]);
  const outboundBatchLoading = ref(false);
  const outboundSelectVisible = ref(false);
  const outboundTableRef = ref(null);
  const dialogOutboundSelection = ref([]);
  const getReceiptMethodLabel = value => {
    if (value === undefined || value === null || value === "") return "-";
    const item = payment_methods.value?.find(
      m => String(m.value) === String(value)
    );
    return item?.label ?? value;
  };
  const getDefaultReceiptMethod = () => payment_methods.value?.[0]?.value ?? "";
  const form = reactive({
    receiptCode: "",
    customerId: "",
    receiptDate: "",
    amount: 0,
    receiptMethod: "",
    stockOutRecordIds: [],
    outboundBatches: "",
    remark: "",
  });
  const rules = {
    customerId: [{ required: true, message: "请选择客户", trigger: "change" }],
    stockOutRecordIds: [
      {
        required: true,
        type: "array",
        min: 1,
        message: "请选择关联单据",
        trigger: "change",
      },
    ],
    receiptDate: [
      { required: true, message: "请选择收款日期", trigger: "change" },
    ],
    amount: [{ required: true, message: "请输入收款金额", trigger: "blur" }],
    receiptMethod: [
      { required: true, message: "请选择收款方式", trigger: "change" },
    ],
  };
  const totalReceiptAmount = computed(() =>
    dataList.value.reduce((sum, item) => sum + Number(item.amount || 0), 0)
  );
  const formatMoney = value => {
    if (value === undefined || value === null) return "0.00";
    return Number(value)
      .toFixed(2)
      .replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  };
  const parseStockOutRecordIds = value => {
    if (!value) return [];
    if (Array.isArray(value)) return value;
    return String(value)
      .split(/[,,]/)
      .map(s => s.trim())
      .filter(Boolean)
      .map(s => (/^\d+$/.test(s) ? Number(s) : s));
  };
  const formatOutboundBatches = value => {
    if (value === undefined || value === null || value === "") return "";
    if (Array.isArray(value)) return value.filter(Boolean).join("、");
    return String(value)
      .split(/[,,]/)
      .map(s => s.trim())
      .filter(Boolean)
      .join("、");
  };
  const normalizeTableRow = row => ({
    ...row,
    receiptCode: row.collectionNumber ?? row.receiptCode,
    receiptDate: row.collectionDate ?? row.receiptDate,
    amount: row.collectionAmount ?? row.amount,
    receiptMethod: row.collectionMethod ?? row.receiptMethod ?? "",
    stockOutRecordIds: row.stockOutRecordIds ?? row.stockOutRecordId ?? "",
    outboundBatches: formatOutboundBatches(row.outboundBatches),
  });
  const getCustomerList = () => {
    listCustomer({ current: -1, size: -1, type: 0 }).then(res => {
      if (res.code === 200) {
        customerList.value = res.data?.records || [];
      }
    });
  };
  const normalizeOutboundBatchOptions = data => {
    const list = Array.isArray(data) ? data : [];
    return list.map((item, index) => {
      if (typeof item === "string" || typeof item === "number") {
        const text = String(item);
        return { label: text, value: text, outboundAmount: 0 };
      }
      const label =
        item.outboundBatches ??
        item.batchNo ??
        item.shippingNo ??
        item.outboundNo ??
        item.label ??
        `出库单${index + 1}`;
      const value = item.id ?? item.stockOutRecordId ?? label;
      return {
        label: String(label),
        value,
        outboundAmount: Number(item.outboundAmount) || 0,
      };
    });
  };
  const isSameOutboundId = (a, b) => String(a) === String(b);
  const getOutboundRowId = row => row?.id ?? row?.stockOutRecordId;
  const outboundBatchDisplayText = computed(() => {
    if (isEdit.value || isView.value) {
      return form.outboundBatches || "";
    }
    if (form.outboundBatches) return form.outboundBatches;
    const ids = form.stockOutRecordIds || [];
    if (!ids.length) return "";
    const labels = outboundBatchOptions.value
      .filter(opt => ids.some(id => isSameOutboundId(id, opt.value)))
      .map(opt => opt.label);
    if (labels.length) return labels.join("、");
    return ids.join("、");
  });
  const handleOutboundInputClick = () => {
    if (isEdit.value || isView.value) return;
    openOutboundSelectDialog();
  };
  /** ä¸ºå·²é€‰ ID è¡¥å…¨é€‰é¡¹ï¼ˆç¼–辑/查看回显) */
  const ensureOutboundOptionsForSelected = () => {
    const ids = form.stockOutRecordIds || [];
    ids.forEach(id => {
      const exists = outboundBatchOptions.value.some(opt =>
        isSameOutboundId(opt.value, id)
      );
      if (exists) return;
      const fromList = outboundBatchList.value.find(row =>
        isSameOutboundId(getOutboundRowId(row), id)
      );
      if (fromList) {
        const [option] = normalizeOutboundBatchOptions([fromList]);
        if (option) outboundBatchOptions.value.push(option);
        return;
      }
      outboundBatchOptions.value.push({
        label: String(id),
        value: id,
        outboundAmount: 0,
      });
    });
  };
  const syncCollectionAmount = () => {
    const selected = form.stockOutRecordIds || [];
    const sum = outboundBatchOptions.value
      .filter(opt => selected.some(id => isSameOutboundId(id, opt.value)))
      .reduce((acc, opt) => acc + (Number(opt.outboundAmount) || 0), 0);
    form.amount = sum > 0 ? Number(sum.toFixed(2)) : 0;
  };
  const restoreOutboundTableSelection = () => {
    nextTick(() => {
      const table = outboundTableRef.value;
      if (!table) return;
      table.clearSelection();
      const selectedIds = new Set(
        (form.stockOutRecordIds || []).map(id => String(id))
      );
      outboundBatchList.value.forEach(row => {
        const rowId = getOutboundRowId(row);
        if (
          rowId !== undefined &&
          rowId !== null &&
          selectedIds.has(String(rowId))
        ) {
          table.toggleRowSelection(row, true);
        }
      });
    });
  };
  const loadOutboundBatches = (customerId, keepSelected = false) => {
    if (!customerId) {
      outboundBatchList.value = [];
      outboundBatchOptions.value = [];
      if (!keepSelected) {
        form.stockOutRecordIds = [];
        form.amount = 0;
      }
      return Promise.resolve();
    }
    outboundBatchLoading.value = true;
    return getOutboundBatchesByCustomer({ customerId })
      .then(res => {
        if (res.code === 200) {
          const list = res.data?.records ?? res.data ?? [];
          outboundBatchList.value = Array.isArray(list) ? list : [];
          outboundBatchOptions.value = normalizeOutboundBatchOptions(list);
        } else {
          outboundBatchList.value = [];
          outboundBatchOptions.value = [];
        }
      })
      .catch(() => {
        outboundBatchList.value = [];
        outboundBatchOptions.value = [];
      })
      .finally(() => {
        outboundBatchLoading.value = false;
        if (keepSelected) {
          ensureOutboundOptionsForSelected();
          restoreOutboundTableSelection();
        }
      });
  };
  const handleCustomerChange = customerId => {
    form.stockOutRecordIds = [];
    form.outboundBatches = "";
    form.amount = 0;
    loadOutboundBatches(customerId);
  };
  const openOutboundSelectDialog = () => {
    if (!form.customerId || isEdit.value || isView.value) return;
    outboundSelectVisible.value = true;
    loadOutboundBatches(form.customerId, true).then(() => {
      restoreOutboundTableSelection();
    });
  };
  const handleOutboundDialogSelectionChange = selection => {
    dialogOutboundSelection.value = selection;
  };
  const confirmOutboundSelection = () => {
    if (dialogOutboundSelection.value.length === 0) {
      ElMessage.warning("请至少选择一条关联单据");
      return;
    }
    form.stockOutRecordIds = dialogOutboundSelection.value
      .map(row => getOutboundRowId(row))
      .filter(id => id !== undefined && id !== null);
    form.outboundBatches = dialogOutboundSelection.value
      .map(row => row.outboundBatches ?? row.batchNo ?? row.shippingNo ?? "")
      .filter(Boolean)
      .join("、");
    outboundSelectVisible.value = false;
    syncCollectionAmount();
    formRef.value?.validateField("stockOutRecordIds");
  };
  const handleOutboundDialogClosed = () => {
    dialogOutboundSelection.value = [];
  };
  const appendFilterParams = params => {
    if (filters.collectionNumber) {
      params.collectionNumber = filters.collectionNumber;
    }
    if (filters.customerId) {
      params.customerId = filters.customerId;
    }
    if (filters.collectionMethod) {
      params.collectionMethod = filters.collectionMethod;
    }
    if (filters.dateRange?.length === 2) {
      params.startDate = filters.dateRange[0];
      params.endDate = filters.dateRange[1];
    }
    return params;
  };
  const buildListParams = () =>
    appendFilterParams({
      current: pagination.currentPage,
      size: pagination.pageSize,
    });
  const buildExportParams = () => appendFilterParams({});
  const buildSubmitPayload = (forUpdate = false) => {
    const payload = {
      customerId: form.customerId,
      collectionDate: form.receiptDate,
      collectionAmount: form.amount,
      collectionMethod: form.receiptMethod,
      collectionNumber: form.receiptCode || "",
      remark: form.remark || "",
      stockOutRecordIds: (form.stockOutRecordIds || []).join(","),
    };
    if (forUpdate) {
      payload.id = currentId.value;
    }
    return payload;
  };
  const fillFormFromRow = row => {
    const stockOutRecordIds = parseStockOutRecordIds(
      row.stockOutRecordIds ?? row.stockOutRecordId
    );
    Object.assign(form, {
      receiptCode: row.receiptCode ?? row.collectionNumber ?? "",
      customerId: row.customerId,
      receiptDate: row.receiptDate ?? row.collectionDate ?? "",
      amount: Number(row.amount ?? row.collectionAmount ?? 0),
      receiptMethod: row.receiptMethod ?? row.collectionMethod ?? "",
      stockOutRecordIds,
      outboundBatches: formatOutboundBatches(row.outboundBatches),
      remark: row.remark ?? "",
    });
  };
  const closeDialog = () => {
    dialogVisible.value = false;
    outboundSelectVisible.value = false;
    isView.value = false;
    isEdit.value = false;
  };
  const handleExport = () => {
    const params = buildExportParams();
    proxy.download(
      "/accountSalesCollection/exportAccountSalesCollection",
      params,
      `收款单_${Date.now()}.xlsx`
    );
  };
  const getTableData = () => {
    tableLoading.value = true;
    listPageAccountSalesCollection(buildListParams())
      .then(res => {
        const ok = res.code === 200 || res.code === 0;
        if (ok && res.data) {
          pagination.total = res.data.total ?? 0;
          dataList.value = (res.data.records ?? []).map(normalizeTableRow);
        } else {
          ElMessage.error(res.msg || "查询失败");
          dataList.value = [];
          pagination.total = 0;
        }
      })
      .catch(() => {
        dataList.value = [];
        pagination.total = 0;
        ElMessage.error("查询失败");
      })
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const onSearch = () => {
    pagination.currentPage = 1;
    getTableData();
  };
  const resetFilters = () => {
    filters.collectionNumber = "";
    filters.customerId = "";
    filters.collectionMethod = "";
    filters.dateRange = [];
    pagination.currentPage = 1;
    getTableData();
  };
  const changePage = ({ current, size }) => {
    pagination.currentPage = current;
    pagination.pageSize = size;
    getTableData();
  };
  const add = () => {
    isEdit.value = false;
    isView.value = false;
    dialogTitle.value = "新增收款";
    Object.assign(form, {
      receiptCode: "SK" + Date.now().toString().slice(-8),
      customerId: "",
      receiptDate: new Date().toISOString().split("T")[0],
      amount: 0,
      receiptMethod: getDefaultReceiptMethod(),
      stockOutRecordIds: [],
      outboundBatches: "",
      remark: "",
    });
    outboundBatchList.value = [];
    outboundBatchOptions.value = [];
    dialogVisible.value = true;
  };
  const edit = row => {
    isEdit.value = true;
    isView.value = false;
    currentId.value = row.id;
    dialogTitle.value = "编辑收款";
    fillFormFromRow(row);
    dialogVisible.value = true;
  };
  const view = row => {
    isView.value = true;
    isEdit.value = false;
    dialogTitle.value = "查看收款";
    fillFormFromRow(row);
    dialogVisible.value = true;
  };
  const handleDelete = row => {
    ElMessageBox.confirm(
      `确认删除收款单「${row.receiptCode ?? row.collectionNumber}」吗?`,
      "提示",
      {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning",
      }
    ).then(() => {
      deleteAccountSalesCollection([row.id])
        .then(res => {
          if (res.code === 200) {
            ElMessage.success("删除成功");
            getTableData();
          } else {
            ElMessage.error(res.msg || "删除失败");
          }
        })
        .catch(() => {
          ElMessage.error("删除失败");
        });
    });
  };
  const submitForm = () => {
    formRef.value.validate(valid => {
      if (!valid) return;
      submitLoading.value = true;
      const request = isEdit.value
        ? updateAccountSalesCollection(buildSubmitPayload(true))
        : addAccountSalesCollection(buildSubmitPayload());
      request
        .then(res => {
          if (res.code === 200) {
            ElMessage.success(isEdit.value ? "修改成功" : "新增成功");
            closeDialog();
            getTableData();
          } else {
            ElMessage.error(res.msg || (isEdit.value ? "修改失败" : "新增失败"));
          }
        })
        .catch(() => {
          ElMessage.error(isEdit.value ? "修改失败" : "新增失败");
        })
        .finally(() => {
          submitLoading.value = false;
        });
    });
  };
  onMounted(() => {
    getCustomerList();
    getTableData();
  });
</script>
<style lang="scss" scoped>
  .actions {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 15px;
  }
  .text-success {
    color: #67c23a;
    font-weight: bold;
  }
  .outbound-batch-input:not(.is-disabled) {
    :deep(.el-input__wrapper) {
      cursor: pointer;
    }
  }
</style>
src/views/financialManagement/receivable/reconciliation.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,738 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="客户:">
        <el-select v-model="filters.customerId" placeholder="请选择客户" clearable filterable style="width: 200px;">
          <el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="对账期间:">
        <el-date-picker v-model="filters.startMonth" type="month" placeholder="开始月份" value-format="YYYY-MM" style="width: 140px;" />
        <span style="margin: 0 10px;">至</span>
        <el-date-picker v-model="filters.endMonth" type="month" placeholder="结束月份" value-format="YYYY-MM" style="width: 140px;" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="getTableData">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div>
          <el-button type="primary" @click="generateStatement" icon="Document">生成对账单</el-button>
        </div>
        <div>
          <el-button @click="handleOut" icon="Download">导出对账单</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      >
        <template #openingBalance="{ row }">
          <span :class="row.openingBalance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(row.openingBalance) }}</span>
        </template>
        <template #currentPlan="{ row }">
          <span class="text-primary">Â¥{{ formatMoney(row.currentPlan) }}</span>
        </template>
        <template #currentActually="{ row }">
          <span class="text-success">Â¥{{ formatMoney(row.currentActually) }}</span>
        </template>
        <template #closingBalance="{ row }">
          <span :class="row.closingBalance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(row.closingBalance) }}</span>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="viewDetail(row)">查看明细</el-button>
          <!-- <el-button type="primary" link @click="printStatement(row)">打印</el-button> -->
          <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog title="对账明细" v-model="detailDialogVisible" width="900px" @confirm="printDetail" @cancel="detailDialogVisible = false" operationType="detail">
      <div class="statement-header">
        <h3>{{ currentCustomer }} åº”收对账单</h3>
        <p>对账期间: {{ currentPeriod }}</p>
      </div>
      <el-table :data="detailData" border style="width: 100%" v-loading="detailLoading">
        <el-table-column prop="date" label="日期" width="120" />
        <el-table-column prop="type" label="类型" width="100">
          <template #default="{ row }">
            <el-tag :type="row.type === '出库' ? 'success' : row.type === '退货' ? 'danger' : 'primary'">{{ row.type }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="code" label="单据编号" width="150" />
        <el-table-column prop="debit" label="借方(应收)" width="120">
          <template #default="{ row }">
            <span v-if="row.debit > 0" class="text-danger">Â¥{{ formatMoney(row.debit) }}</span>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column prop="credit" label="贷方(收款)" width="120">
          <template #default="{ row }">
            <span v-if="row.credit > 0" class="text-success">Â¥{{ formatMoney(row.credit) }}</span>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column prop="balance" label="余额" width="120">
          <template #default="{ row }">
            <span :class="row.balance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(row.balance) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="remark" label="备注" show-overflow-tooltip />
      </el-table>
      <template #footer>
        <el-button type="primary" @click="printDetail">打印</el-button>
        <el-button @click="detailDialogVisible = false">关闭</el-button>
      </template>
    </FormDialog>
    <FormDialog title="生成对账单" v-model="generateDialogVisible" width="1000px" @confirm="confirmGenerate" @cancel="generateDialogVisible = false">
      <el-form :model="generateForm" label-width="100px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="选择客户" prop="customerId">
              <el-select
                v-model="generateForm.customerId"
                placeholder="请选择客户"
                style="width: 100%;"
                filterable
                @change="onCustomerChange"
              >
                <el-option v-for="item in customerList" :key="item.id" :label="item.customerName" :value="item.id" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="对账月份" prop="statementMonth">
              <el-date-picker v-model="generateForm.statementMonth" type="month" placeholder="选择月份" value-format="YYYY-MM" style="width: 100%;" @change="onStatementMonthChange" />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <div v-if="statementDetailLoaded" class="sales-section">
        <div v-if="salesData.length > 0" class="section-title">本月销售数据</div>
        <el-table
          v-if="salesData.length > 0"
          ref="salesTableRef"
          :data="salesData"
          border
          row-key="id"
          style="width: 100%; margin-bottom: 15px;"
          v-loading="salesLoading"
          @selection-change="handleSalesSelectionChange"
        >
          <el-table-column type="selection" width="55" align="center" />
          <el-table-column prop="occurrenceDate" label="日期" width="120" />
          <el-table-column prop="receiptNumber" label="单据编号" width="150" />
          <el-table-column prop="type" label="类型" width="100">
            <template #default="{ row }">
              <el-tag :type="getDetailTypeTagType(row.type)">{{ row.typeLabel }}</el-tag>
            </template>
          </el-table-column>
          <el-table-column prop="amount" label="金额" width="120">
            <template #default="{ row }">
              <span :class="getDetailAmountClass(row.type)">Â¥{{ formatMoney(row.amount) }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="remark" label="备注" />
        </el-table>
        <el-empty v-else description="该客户本月暂无明细数据" :image-size="80" />
        <div class="summary-row">
          <span>期初余额: <strong class="text-primary">Â¥{{ formatMoney(generateForm.openingBalance) }}</strong></span>
          <span>本期应收: <strong class="text-primary">Â¥{{ formatMoney(generateForm.currentPlan) }}</strong></span>
          <span>本期收款: <strong class="text-success">Â¥{{ formatMoney(generateForm.currentActually) }}</strong></span>
          <span>期末余额: <strong :class="displayClosingBalance >= 0 ? 'text-success' : 'text-danger'">Â¥{{ formatMoney(displayClosingBalance) }}</strong></span>
        </div>
      </div>
      <div v-else-if="generateForm.customerId && generateForm.statementMonth && !salesLoading" class="empty-tip">
        <el-empty description="该客户本月暂无销售数据" />
      </div>
      <template #footer>
        <el-button type="primary" @click="confirmGenerate" :disabled="!canGenerate" :loading="submitLoading">确认生成</el-button>
        <el-button @click="generateDialogVisible = false">取消</el-button>
      </template>
    </FormDialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, nextTick, getCurrentInstance } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { listCustomer } from "@/api/basicData/customer.js";
import {
  getAccountStatementDetailsByMonth,
  addAccountStatement,
  listPageAccountStatement,
  deleteAccountStatement,
} from "@/api/financialManagement/accountStatement.js";
const ACCOUNT_TYPE_RECEIVABLE = 1;
const { proxy } = getCurrentInstance();
defineOptions({
  name: "应收对账",
});
const filters = reactive({
  customerId: "",
  startMonth: "",
  endMonth: "",
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "对账单号", prop: "statementNumber", width: "150" },
  { label: "客户名称", prop: "customerName", width: "180" },
  { label: "对账期间", prop: "statementMonth", width: "150" },
  { label: "期初余额", prop: "openingBalance", dataType: "slot", slot: "openingBalance" },
  { label: "本期应收", prop: "currentPlan", dataType: "slot", slot: "currentPlan" },
  { label: "本期收款", prop: "currentActually", dataType: "slot", slot: "currentActually" },
  { label: "期末余额", prop: "closingBalance", dataType: "slot", slot: "closingBalance" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "200", fixed: "right" },
];
const dataList = ref([]);
const tableLoading = ref(false);
const submitLoading = ref(false);
const detailDialogVisible = ref(false);
const currentCustomer = ref("");
const currentPeriod = ref("");
const detailData = ref([]);
const detailLoading = ref(false);
const generateDialogVisible = ref(false);
const salesLoading = ref(false);
const statementDetailLoaded = ref(false);
const salesData = ref([]);
const selectedSales = ref([]);
const salesTableRef = ref(null);
const customerList = ref([]);
/** æ˜Žç»† type:1出库 2入库 3收款 4付款 5退货 */
const STATEMENT_DETAIL_TYPE_MAP = {
  1: "出库",
  2: "入库",
  3: "收款",
  4: "付款",
  5: "退货",
};
const calculateEndBalance = (openingBalance, currentPlan, currentActually) => {
  return openingBalance + currentPlan - currentActually;
};
const getDetailTypeLabel = (type) => STATEMENT_DETAIL_TYPE_MAP[Number(type)] ?? "";
const getDetailTypeTagType = (type) => {
  const t = Number(type);
  if (t === 1) return "success";
  if (t === 3) return "primary";
  if (t === 5) return "danger";
  return "info";
};
const getDetailAmountClass = (type) => {
  const t = Number(type);
  if (t === 1) return "text-primary";
  if (t === 3) return "text-success";
  return "text-danger";
};
const generateForm = reactive({
  customerId: "",
  customerName: "",
  statementMonth: "",
  openingBalance: 0,
  currentPlan: 0,
  currentActually: 0,
  closingBalance: 0,
});
const displayClosingBalance = computed(() => {
  return calculateEndBalance(
    generateForm.openingBalance,
    generateForm.currentPlan,
    generateForm.currentActually
  );
});
const canGenerate = computed(() => {
  return generateForm.customerId && generateForm.statementMonth && selectedSales.value.length > 0;
});
const applyStatementSummary = (data) => {
  generateForm.openingBalance = Number(data.openingBalance ?? 0);
  generateForm.currentPlan = Number(data.currentPlan ?? 0);
  generateForm.currentActually = Number(data.currentActually ?? 0);
  generateForm.closingBalance = Number(
    data.closingBalance ??
      calculateEndBalance(
        generateForm.openingBalance,
        generateForm.currentPlan,
        generateForm.currentActually
      )
  );
};
const getCustomerList = () => {
  listCustomer({ current: -1, size: -1, type: 0 }).then((res) => {
    if (res.code === 200) {
      customerList.value = res.data?.records || [];
    }
  });
};
const normalizeSalesRows = (list) => {
  const rows = Array.isArray(list) ? list : [];
  return rows.map((item, index) => {
    const type = Number(item.type);
    return {
      id: item.id ?? `detail-${index}`,
      accountStatementId: item.accountStatementId,
      occurrenceDate: item.occurrenceDate ?? "",
      receiptNumber: item.receiptNumber ?? "",
      type,
      typeLabel: getDetailTypeLabel(type),
      amount: Math.abs(Number(item.amount ?? 0)),
      remark: item.remark ?? "",
    };
  });
};
const selectAllSalesRows = (keepApiSummary = false) => {
  nextTick(() => {
    const table = salesTableRef.value;
    if (!table) return;
    table.clearSelection();
    salesData.value.forEach((row) => table.toggleRowSelection(row, true));
    selectedSales.value = [...salesData.value];
    if (!keepApiSummary) {
      calculateSummary();
    }
  });
};
const isNumericId = (id) => id !== undefined && id !== null && id !== "" && /^\d+$/.test(String(id));
const buildFilterParams = (params = {}) => {
  const result = { ...params, accountType: ACCOUNT_TYPE_RECEIVABLE };
  if (filters.customerId) {
    result.customerId = filters.customerId;
  }
  if (filters.startMonth && filters.endMonth && filters.startMonth === filters.endMonth) {
    result.statementMonth = filters.startMonth;
  } else if (filters.startMonth) {
    result.startMonth = filters.startMonth;
  }
  if (filters.endMonth && filters.startMonth !== filters.endMonth) {
    result.endMonth = filters.endMonth;
  }
  return result;
};
const buildListParams = () =>
  buildFilterParams({
    current: pagination.currentPage,
    size: pagination.pageSize,
  });
const buildExportParams = () => buildFilterParams({});
const buildDetailSubmitItem = (row) => {
  const item = {
    occurrenceDate: row.occurrenceDate,
    receiptNumber: row.receiptNumber,
    type: row.type,
    amount: row.amount,
    remark: row.remark ?? "",
  };
  if (isNumericId(row.id)) {
    item.id = Number(row.id);
  }
  if (row.accountStatementId) {
    item.accountStatementId = row.accountStatementId;
  }
  return item;
};
const buildAddPayload = () => ({
  customerId: generateForm.customerId,
  customerName: generateForm.customerName,
  statementMonth: generateForm.statementMonth,
  accountType: ACCOUNT_TYPE_RECEIVABLE,
  statementNumber: "",
  openingBalance: generateForm.openingBalance,
  currentPlan: generateForm.currentPlan,
  currentActually: generateForm.currentActually,
  closingBalance: generateForm.closingBalance,
  accountStatementDetails: selectedSales.value.map(buildDetailSubmitItem),
});
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountStatement(buildListParams())
    .then((res) => {
      const ok = res.code === 200 || res.code === 0;
      if (ok && res.data) {
        pagination.total = res.data.total ?? 0;
        dataList.value = res.data.records ?? [];
      } else {
        ElMessage.error(res.msg || "查询失败");
        dataList.value = [];
        pagination.total = 0;
      }
    })
    .catch(() => {
      dataList.value = [];
      pagination.total = 0;
      ElMessage.error("查询失败");
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const resetFilters = () => {
  filters.customerId = "";
  filters.startMonth = "";
  filters.endMonth = "";
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ current, size }) => {
  pagination.currentPage = current;
  pagination.pageSize = size;
  getTableData();
};
const generateStatement = () => {
  generateForm.customerId = "";
  generateForm.customerName = "";
  generateForm.statementMonth = "";
  generateForm.openingBalance = 0;
  generateForm.currentPlan = 0;
  generateForm.currentActually = 0;
  generateForm.closingBalance = 0;
  statementDetailLoaded.value = false;
  salesData.value = [];
  selectedSales.value = [];
  generateDialogVisible.value = true;
};
const onCustomerChange = (customerId) => {
  const customer = customerList.value.find((item) => item.id === customerId);
  generateForm.customerName = customer?.customerName ?? "";
  loadSalesData();
};
const onStatementMonthChange = () => {
  loadSalesData();
};
const loadSalesData = () => {
  if (!generateForm.customerId || !generateForm.statementMonth) {
    salesData.value = [];
    selectedSales.value = [];
    statementDetailLoaded.value = false;
    generateForm.openingBalance = 0;
    generateForm.currentPlan = 0;
    generateForm.currentActually = 0;
    generateForm.closingBalance = 0;
    return;
  }
  salesLoading.value = true;
  selectedSales.value = [];
  statementDetailLoaded.value = false;
  getAccountStatementDetailsByMonth({
    accountType: ACCOUNT_TYPE_RECEIVABLE,
    customerId: generateForm.customerId,
    statementMonth: generateForm.statementMonth,
  })
    .then((res) => {
      if (res.code === 200) {
        const data = res.data ?? {};
        const details = data.accountStatementDetails;
        const list = Array.isArray(details) ? details : [];
        salesData.value = normalizeSalesRows(list);
        applyStatementSummary(data);
        statementDetailLoaded.value = true;
        if (salesData.value.length > 0) {
          selectAllSalesRows(true);
        }
      } else {
        salesData.value = [];
        statementDetailLoaded.value = false;
        ElMessage.error(res.msg || "查询对账明细失败");
      }
    })
    .catch(() => {
      salesData.value = [];
      statementDetailLoaded.value = false;
      ElMessage.error("查询对账明细失败");
    })
    .finally(() => {
      salesLoading.value = false;
    });
};
const calculateSummary = () => {
  let receivable = 0;
  let receipt = 0;
  selectedSales.value.forEach((item) => {
    if (item.type === 1) {
      receivable += item.amount;
    } else if (item.type === 5) {
      receivable -= item.amount;
    } else if (item.type === 3) {
      receipt += item.amount;
    }
  });
  generateForm.currentPlan = receivable;
  generateForm.currentActually = receipt;
  generateForm.closingBalance = calculateEndBalance(
    generateForm.openingBalance,
    generateForm.currentPlan,
    generateForm.currentActually
  );
};
const handleSalesSelectionChange = (selection) => {
  selectedSales.value = selection;
  calculateSummary();
};
const confirmGenerate = () => {
  if (!canGenerate.value) return;
  submitLoading.value = true;
  addAccountStatement(buildAddPayload())
    .then((res) => {
      if (res.code === 200) {
        generateDialogVisible.value = false;
        ElMessage.success("对账单生成成功");
        pagination.currentPage = 1;
        getTableData();
      } else {
        ElMessage.error(res.msg || "生成失败");
      }
    })
    .catch(() => {
      ElMessage.error("生成失败");
    })
    .finally(() => {
      submitLoading.value = false;
    });
};
const handleDelete = (row) => {
  ElMessageBox.confirm(`确认删除对账单「${row.statementNumber || row.id}」吗?`, "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    deleteAccountStatement([row.id])
      .then((res) => {
        if (res.code === 200) {
          ElMessage.success("删除成功");
          getTableData();
        } else {
          ElMessage.error(res.msg || "删除失败");
        }
      })
      .catch(() => {
        ElMessage.error("删除失败");
      });
  });
};
const buildDetailTableFromApi = (data, statementMonth) => {
  const details = Array.isArray(data.accountStatementDetails) ? data.accountStatementDetails : [];
  let runningBalance = Number(data.openingBalance ?? 0);
  const rows = [
    {
      date: statementMonth ?? "",
      type: "期初",
      code: "-",
      debit: 0,
      credit: 0,
      balance: runningBalance,
      remark: "期初余额",
    },
  ];
  details.forEach((item) => {
    const amount = Math.abs(Number(item.amount ?? 0));
    const type = Number(item.type);
    let debit = 0;
    let credit = 0;
    if (type === 1) {
      debit = amount;
      runningBalance += amount;
    } else if (type === 3 || type === 5) {
      credit = amount;
      runningBalance -= amount;
    }
    rows.push({
      date: item.occurrenceDate ?? "",
      type: getDetailTypeLabel(type),
      code: item.receiptNumber ?? "",
      debit,
      credit,
      balance: runningBalance,
      remark: item.remark ?? "",
    });
  });
  return rows;
};
const viewDetail = (row) => {
  if (!row.customerId || !row.statementMonth) {
    ElMessage.warning("缺少客户或对账月份,无法查询明细");
    return;
  }
  currentCustomer.value = row.customerName ?? "";
  currentPeriod.value = row.statementMonth ?? "";
  detailData.value = [];
  detailDialogVisible.value = true;
  detailLoading.value = true;
  getAccountStatementDetailsByMonth({
    accountType: ACCOUNT_TYPE_RECEIVABLE,
    customerId: row.customerId,
    statementMonth: row.statementMonth,
  })
    .then((res) => {
      if (res.code === 200) {
        detailData.value = buildDetailTableFromApi(res.data ?? {}, row.statementMonth);
      } else {
        ElMessage.error(res.msg || "查询明细失败");
        detailDialogVisible.value = false;
      }
    })
    .catch(() => {
      ElMessage.error("查询明细失败");
      detailDialogVisible.value = false;
    })
    .finally(() => {
      detailLoading.value = false;
    });
};
const printStatement = (row) => {
  ElMessage.info(`打印对账单: ${row.statementNumber}`);
};
const printDetail = () => {
  ElMessage.info("打印明细");
};
const handleOut = () => {
  const params = buildExportParams();
  proxy.download("/accountStatement/exportAccountStatement", params, `应收对账单_${Date.now()}.xlsx`);
};
onMounted(() => {
  getCustomerList();
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
.text-success {
  color: #67c23a;
}
.text-danger {
  color: #f56c6c;
}
.text-primary {
  color: #409eff;
}
.statement-header {
  text-align: center;
  margin-bottom: 20px;
  h3 {
    margin: 0 0 10px 0;
  }
  p {
    color: #909399;
    margin: 0;
  }
}
.sales-section {
  margin-top: 20px;
  .section-title {
    font-size: 16px;
    font-weight: bold;
    margin-bottom: 15px;
    padding-left: 10px;
    border-left: 4px solid #409eff;
  }
}
.summary-row {
  display: flex;
  justify-content: space-around;
  padding: 15px;
  background-color: #f5f7fa;
  border-radius: 4px;
  margin-top: 15px;
  span {
    font-size: 14px;
    strong {
      font-size: 16px;
      margin-left: 5px;
    }
  }
}
.empty-tip {
  margin-top: 30px;
}
</style>
src/views/financialManagement/receivable/salesOut.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<template>
  <!-- é”€å”®å‡ºåº“ -->
  <div class="app-container">
    <el-form :model="filters"
             :inline="true">
      <el-form-item label="出库单号:">
        <el-input v-model="filters.outboundBatches"
                  placeholder="请输入出库单号"
                  clearable
                  style="width: 200px;" />
      </el-form-item>
      <el-form-item label="客户名称:">
        <el-input v-model="filters.customerName"
                  placeholder="请输入客户名称"
                  clearable
                  style="width: 200px;" />
      </el-form-item>
      <el-form-item label="出库日期:">
        <el-date-picker v-model="filters.dateRange"
                        value-format="YYYY-MM-DD"
                        format="YYYY-MM-DD"
                        type="daterange"
                        range-separator="至"
                        start-placeholder="开始日期"
                        end-placeholder="结束日期"
                        clearable />
      </el-form-item>
      <el-form-item>
        <el-button type="primary"
                   @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button @click="handleOut"
                     icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable rowKey="id"
                :column="columns"
                :tableData="dataList"
                :tableLoading="tableLoading"
                :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
                @pagination="changePage" />
    </div>
  </div>
</template>
<script setup>
  import { ref, reactive, onMounted, getCurrentInstance } from "vue";
  import { ElMessage } from "element-plus";
  import { listPageAccountSales } from "@/api/financialManagement/accountSales";
  defineOptions({
    name: "销售出库",
  });
  const { proxy } = getCurrentInstance();
  const filters = reactive({
    outboundBatches: "",
    customerName: "",
    dateRange: [],
  });
  const pagination = reactive({
    currentPage: 1,
    pageSize: 10,
    total: 0,
  });
  const columns = [
    { label: "出库单号", prop: "outboundBatches", minWidth: "150" },
    { label: "客户名称", prop: "customerName", minWidth: "180" },
    { label: "出库日期", prop: "shippingDate", width: "170" },
    { label: "产品名称", prop: "productName", minWidth: "140" },
    { label: "规格型号", prop: "specificationModel", minWidth: "140" },
    {
      label: "金额",
      prop: "outboundAmount",
      minWidth: "120",
      align: "right",
      formatData: val =>
        val === null || val === undefined || val === ""
          ? ""
          : Number(val).toLocaleString("zh-CN", {
              minimumFractionDigits: 2,
              maximumFractionDigits: 2,
            }),
    },
    { label: "发货编号", prop: "shippingNo", minWidth: "140" },
    { label: "销售订单号", prop: "salesContractNo", minWidth: "150" },
  ];
  const dataList = ref([]);
  const tableLoading = ref(false);
  function buildFilterParams() {
    const params = {
      outboundBatches: filters.outboundBatches || undefined,
      customerName: filters.customerName || undefined,
    };
    if (filters.dateRange && filters.dateRange.length === 2) {
      params.startDate = filters.dateRange[0];
      params.endDate = filters.dateRange[1];
    }
    return params;
  }
  const onSearch = () => {
    pagination.currentPage = 1;
    getTableData();
  };
  const getTableData = () => {
    tableLoading.value = true;
    listPageAccountSales({
      ...buildFilterParams(),
      current: pagination.currentPage,
      size: pagination.pageSize,
    })
      .then(res => {
        const ok = res.code === 200 || res.code === 0;
        if (ok && res.data) {
          pagination.total = res.data.total ?? 0;
          dataList.value = res.data.records ?? [];
        } else {
          ElMessage.error(res.msg || "查询失败");
          dataList.value = [];
        }
      })
      .catch(() => {
        dataList.value = [];
      })
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const resetFilters = () => {
    filters.outboundBatches = "";
    filters.customerName = "";
    filters.dateRange = [];
    pagination.currentPage = 1;
    getTableData();
  };
  const changePage = ({ page, limit }) => {
    pagination.currentPage = page;
    pagination.pageSize = limit;
    getTableData();
  };
  const handleOut = () => {
    proxy.download(
      "/accountSales/exportAccountSalesOutbound",
      buildFilterParams(),
      `销售出库_${new Date().getTime()}.xlsx`
    );
  };
  onMounted(() => {
    getTableData();
  });
</script>
<style lang="scss" scoped>
  .actions {
    display: flex;
    justify-content: space-between;
    margin-bottom: 15px;
  }
</style>
src/views/financialManagement/receivable/salesReturn.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,171 @@
<template>
  <!-- é”€å”®é€€è´§ -->
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="退货单号:">
        <el-input v-model="filters.returnNo" placeholder="请输入退货单号" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="客户名称:">
        <el-input v-model="filters.customerName" placeholder="请输入客户名称" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="退货日期:">
        <el-date-picker
          v-model="filters.dateRange"
          value-format="YYYY-MM-DD"
          format="YYYY-MM-DD"
          type="daterange"
          range-separator="至"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          clearable
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="onSearch">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button @click="handleOut" icon="Download">导出</el-button>
        </div>
      </div>
      <PIMTable
        rowKey="id"
        :column="columns"
        :tableData="dataList"
        :tableLoading="tableLoading"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      />
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, getCurrentInstance } from "vue";
import { ElMessage } from "element-plus";
import { listPageAccountSalesReturn } from "@/api/financialManagement/accountSales";
defineOptions({
  name: "销售退货",
});
const { proxy } = getCurrentInstance();
const filters = reactive({
  returnNo: "",
  customerName: "",
  dateRange: [],
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "退货单号", prop: "returnNo", minWidth: "150" },
  { label: "客户名称", prop: "customerName", minWidth: "180" },
  { label: "关联发货单号", prop: "shippingNo", minWidth: "150" },
  { label: "退货日期", prop: "makeTime", minWidth: "170" },
  {
    label: "退款总额",
    prop: "refundAmount",
    minWidth: "120",
    align: "right",
    formatData: (val) =>
      val === null || val === undefined || val === ""
        ? ""
        : Number(val).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
  },
  { label: "退货原因", prop: "returnReason", minWidth: "150", showOverflowTooltip: true },
  { label: "销售订单号", prop: "salesContractNo", minWidth: "150" },
];
const dataList = ref([]);
const tableLoading = ref(false);
function buildFilterParams() {
  const params = {
    returnNo: filters.returnNo || undefined,
    customerName: filters.customerName || undefined,
  };
  if (filters.dateRange && filters.dateRange.length === 2) {
    params.startDate = filters.dateRange[0];
    params.endDate = filters.dateRange[1];
  }
  return params;
}
const onSearch = () => {
  pagination.currentPage = 1;
  getTableData();
};
const getTableData = () => {
  tableLoading.value = true;
  listPageAccountSalesReturn({
    ...buildFilterParams(),
    current: pagination.currentPage,
    size: pagination.pageSize,
  })
    .then((res) => {
      const ok = res.code === 200 || res.code === 0;
      if (ok && res.data) {
        pagination.total = res.data.total ?? 0;
        dataList.value = res.data.records ?? [];
      } else {
        ElMessage.error(res.msg || "查询失败");
        dataList.value = [];
      }
    })
    .catch(() => {
      dataList.value = [];
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
const resetFilters = () => {
  filters.returnNo = "";
  filters.customerName = "";
  filters.dateRange = [];
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ page, limit }) => {
  pagination.currentPage = page;
  pagination.pageSize = limit;
  getTableData();
};
const handleOut = () => {
  proxy.download(
    "/accountSales/exportAccountSalesReturn",
    buildFilterParams(),
    `销售退货_${new Date().getTime()}.xlsx`
  );
};
onMounted(() => {
  getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  margin-bottom: 15px;
}
</style>
src/views/financialManagement/voucher/detailLedger.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
<template>
  <div class="app-container ledger-page">
    <div class="ledger-layout">
      <aside class="subject-panel">
        <el-input v-model="subjectKeyword" placeholder="请输入科目名称/编号" clearable prefix-icon="Search" />
        <el-scrollbar class="subject-tree-scroll">
          <el-tree
            ref="subjectTreeRef"
            :data="subjectOptions"
            node-key="code"
            :props="{ label: 'name', children: 'children' }"
            highlight-current
            default-expand-all
            :expand-on-click-node="false"
            :filter-node-method="filterSubjectNode"
            @node-click="handleSubjectClick"
          >
            <template #default="{ data }">
              <span class="subject-node">{{ data.code }} {{ data.name }}</span>
            </template>
          </el-tree>
        </el-scrollbar>
      </aside>
      <section class="ledger-content">
        <el-form :model="filters" :inline="true" class="filter-form">
          <el-form-item label="期间:">
            <el-date-picker v-model="filters.startMonth" type="month" placeholder="开始月份" value-format="YYYY-MM" style="width: 140px;" />
            <span style="margin: 0 10px;">至</span>
            <el-date-picker v-model="filters.endMonth" type="month" placeholder="结束月份" value-format="YYYY-MM" style="width: 140px;" />
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="getTableData">查询</el-button>
            <el-button @click="resetFilters">重置</el-button>
<!--            <el-button @click="handlePrint" icon="Printer">打印</el-button>-->
            <el-button @click="handleOut" icon="Download">导出</el-button>
          </el-form-item>
        </el-form>
        <div class="table_list">
          <el-table :data="dataList" border style="width: 100%">
            <el-table-column prop="date" label="日期" width="120" />
            <el-table-column prop="voucherNo" label="凭证字号" width="120" />
            <el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
            <el-table-column prop="debit" label="借方" width="150">
              <template #default="{ row }">
                <span v-if="row.debit > 0" class="text-danger">Â¥{{ formatMoney(row.debit) }}</span>
                <span v-else>-</span>
              </template>
            </el-table-column>
            <el-table-column prop="credit" label="贷方" width="150">
              <template #default="{ row }">
                <span v-if="row.credit > 0" class="text-success">Â¥{{ formatMoney(row.credit) }}</span>
                <span v-else>-</span>
              </template>
            </el-table-column>
            <el-table-column label="方向" width="80">
              <template #default="{ row }">
                <el-tag :type="row.direction === '借' ? 'success' : 'danger'" size="small">{{ row.direction }}</el-tag>
              </template>
            </el-table-column>
            <el-table-column label="余额" width="150">
              <template #default="{ row }">
                <span :class="row.balance >= 0 ? 'text-primary' : 'text-warning'">Â¥{{ formatMoney(Math.abs(row.balance)) }}</span>
              </template>
            </el-table-column>
          </el-table>
        </div>
        <el-empty v-if="!currentSubject" description="请选择会计科目查询" style="margin-top: 50px;" />
      </section>
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { listAccountSubject } from "@/api/financialManagement/accountSubject";
import { getDetailLedger } from "@/api/financialManagement/ledger";
defineOptions({
  name: "科目明细账",
});
const filters = reactive({
  subject: "",
  startMonth: "",
  endMonth: "",
});
const dataList = ref([]);
const subjectOptions = ref([]);
const subjectKeyword = ref("");
const subjectTreeRef = ref();
const getPreviousMonth = () => {
  const date = new Date();
  date.setDate(1);
  date.setMonth(date.getMonth() - 1);
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  return `${year}-${month}`;
};
const defaultMonth = getPreviousMonth();
filters.startMonth = defaultMonth;
filters.endMonth = defaultMonth;
const fallbackSubjects = [
  { code: "1122", name: "应收账款" },
  { code: "2202", name: "应付账款" },
  { code: "6602", name: "管理费用" },
];
const toTree = (nodes = []) =>
  nodes
    .filter(item => item.subjectCode && item.subjectName)
    .map(item => ({
      code: item.subjectCode,
      name: item.subjectName,
      children: toTree(item.children || []),
    }));
const findSubject = (options, code) => {
  for (const item of options) {
    if (item.code === code) return item;
    if (item.children && item.children.length > 0) {
      const found = findSubject(item.children, code);
      if (found) return found;
    }
  }
  return null;
};
const currentSubject = computed(() => {
  if (!filters.subject) return null;
  return findSubject(subjectOptions.value, filters.subject);
});
const getFirstSubjectCode = (nodes = []) => {
  for (const item of nodes) {
    if (item.code) return item.code;
    if (item.children && item.children.length > 0) {
      const childCode = getFirstSubjectCode(item.children);
      if (childCode) return childCode;
    }
  }
  return "";
};
const setDefaultSubjectSelection = async () => {
  const firstCode = getFirstSubjectCode(subjectOptions.value);
  if (!firstCode) {
    filters.subject = "";
    subjectTreeRef.value?.setCurrentKey(null);
    return;
  }
  filters.subject = firstCode;
  await nextTick();
  subjectTreeRef.value?.setCurrentKey(firstCode);
};
const filterSubjectNode = (value, data) => {
  const keyword = value?.trim();
  if (!keyword) return true;
  return `${data.code}${data.name}`.includes(keyword);
};
watch(subjectKeyword, (value) => {
  subjectTreeRef.value?.filter(value || "");
});
const handleSubjectClick = async (data) => {
  filters.subject = data.code;
  await getTableData();
};
const loadSubjectOptions = async () => {
  let options = [];
  try {
    const { data } = await listAccountSubject({
      current: 1,
      size: 1000,
    });
    options = toTree(data?.records || []);
  } catch (error) {
    // å…¨å±€æ‹¦æˆªå™¨å·²æç¤ºï¼Œä¸‹é¢èµ°å…œåº•ç§‘ç›®
  }
  if (options.length === 0) {
    options = fallbackSubjects.map(item => ({ ...item, children: [] }));
  }
  subjectOptions.value = options;
  await setDefaultSubjectSelection();
  if (filters.subject) {
    await getTableData();
  }
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
// è”调约定:明细账按科目与期间过滤
const getTableData = async () => {
  if (!currentSubject.value) {
    dataList.value = [];
    return;
  }
  try {
    const { data } = await getDetailLedger({
      subjectCode: currentSubject.value.code,
      startMonth: filters.startMonth,
      endMonth: filters.endMonth,
    });
    dataList.value = Array.isArray(data) ? data : data?.records || [];
  } catch (error) {
    // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
  }
};
const resetFilters = async () => {
  filters.startMonth = defaultMonth;
  filters.endMonth = defaultMonth;
  dataList.value = [];
  subjectKeyword.value = "";
  subjectTreeRef.value?.filter("");
  await setDefaultSubjectSelection();
  if (filters.subject) {
    await getTableData();
  }
};
const handlePrint = () => {
  ElMessage.info("打印功能");
};
const handleOut = () => {
  ElMessage.success("导出成功");
};
onMounted(async () => {
  await loadSubjectOptions();
});
</script>
<style lang="scss" scoped>
.ledger-layout {
  display: flex;
  gap: 16px;
}
.subject-panel {
  width: 260px;
  flex-shrink: 0;
  padding: 12px;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  background-color: #fff;
}
.subject-tree-scroll {
  height: 600px;
  margin-top: 12px;
}
.subject-node {
  display: inline-flex;
  align-items: center;
}
.ledger-content {
  flex: 1;
  min-width: 0;
}
.filter-form {
  margin-bottom: 12px;
}
.text-primary {
  color: #409eff;
  font-weight: bold;
}
.text-success {
  color: #67c23a;
  font-weight: bold;
}
.text-danger {
  color: #f56c6c;
  font-weight: bold;
}
.text-warning {
  color: #e6a23c;
  font-weight: bold;
}
.subject-panel :deep(.el-tree-node__content) {
  height: 34px;
}
.subject-panel :deep(.el-tree-node.is-current > .el-tree-node__content) {
  background-color: #f0f7ff;
}
</style>
src/views/financialManagement/voucher/generalLedger.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,312 @@
<template>
  <div class="app-container ledger-page">
    <div class="ledger-layout">
      <aside class="subject-panel">
        <el-input v-model="subjectKeyword" placeholder="请输入科目名称/编号" clearable prefix-icon="Search" />
        <el-scrollbar class="subject-tree-scroll">
          <el-tree
            ref="subjectTreeRef"
            :data="subjectOptions"
            node-key="code"
            :props="{ label: 'name', children: 'children' }"
            highlight-current
            default-expand-all
            :expand-on-click-node="false"
            :filter-node-method="filterSubjectNode"
            @node-click="handleSubjectClick"
          >
            <template #default="{ data }">
              <span class="subject-node">{{ data.code }} {{ data.name }}</span>
            </template>
          </el-tree>
        </el-scrollbar>
      </aside>
      <section class="ledger-content">
        <el-form :model="filters" :inline="true" class="filter-form">
          <el-form-item label="期间:">
            <el-date-picker v-model="filters.startMonth" type="month" placeholder="开始月份" value-format="YYYY-MM" style="width: 140px;" />
            <span style="margin: 0 10px;">至</span>
            <el-date-picker v-model="filters.endMonth" type="month" placeholder="结束月份" value-format="YYYY-MM" style="width: 140px;" />
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="getTableData">查询</el-button>
            <el-button @click="resetFilters">重置</el-button>
<!--            <el-button @click="handlePrint" icon="Printer">打印</el-button>-->
            <!-- <el-button @click="handleOut" icon="Download">导出</el-button> -->
          </el-form-item>
        </el-form>
        <div class="table_list">
          <el-table :data="dataList" border style="width: 100%">
            <el-table-column prop="date" label="日期"/>
            <!-- <el-table-column prop="voucherNo" label="凭证字号" width="120" /> -->
            <!-- <el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip /> -->
            <el-table-column prop="debit" label="借方">
              <template #default="{ row }">
                <span v-if="row.debit > 0" class="text-danger">Â¥{{ formatMoney(row.debit) }}</span>
                <span v-else>-</span>
              </template>
            </el-table-column>
            <el-table-column prop="credit" label="贷方">
              <template #default="{ row }">
                <span v-if="row.credit > 0" class="text-success">Â¥{{ formatMoney(row.credit) }}</span>
                <span v-else>-</span>
              </template>
            </el-table-column>
            <el-table-column label="方向">
              <template #default="{ row }">
                <el-tag :type="row.direction === '借' ? 'success' : 'danger'" size="small">{{ row.direction }}</el-tag>
              </template>
            </el-table-column>
            <el-table-column label="余额">
              <template #default="{ row }">
                <span :class="row.balance >= 0 ? 'text-primary' : 'text-warning'">Â¥{{ formatMoney(Math.abs(row.balance)) }}</span>
              </template>
            </el-table-column>
          </el-table>
        </div>
        <el-empty v-if="!currentSubject" description="请选择会计科目查询" style="margin-top: 50px;" />
      </section>
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { listAccountSubject } from "@/api/financialManagement/accountSubject";
import { getGeneralLedger } from "@/api/financialManagement/ledger";
defineOptions({
  name: "科目总账",
});
const filters = reactive({
  subject: "",
  startMonth: "",
  endMonth: "",
});
const dataList = ref([]);
const subjectOptions = ref([]);
const subjectKeyword = ref("");
const subjectTreeRef = ref();
const getPreviousMonth = () => {
  const date = new Date();
  date.setDate(1);
  date.setMonth(date.getMonth() - 1);
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  return `${year}-${month}`;
};
const defaultMonth = getPreviousMonth();
filters.startMonth = defaultMonth;
filters.endMonth = defaultMonth;
const fallbackSubjects = [
  { code: "1001", name: "库存现金" },
  { code: "1002", name: "银行存款" },
  { code: "1122", name: "应收账款" },
  { code: "2202", name: "应付账款" },
  { code: "6001", name: "主营业务收入" },
];
const toTree = (nodes = []) =>
  nodes
    .filter(item => item.subjectCode && item.subjectName)
    .map(item => ({
      code: item.subjectCode,
      name: item.subjectName,
      children: toTree(item.children || []),
    }));
const findSubject = (options, code) => {
  for (const item of options) {
    if (item.code === code) return item;
    if (item.children && item.children.length > 0) {
      const found = findSubject(item.children, code);
      if (found) return found;
    }
  }
  return null;
};
const currentSubject = computed(() => {
  if (!filters.subject) return null;
  return findSubject(subjectOptions.value, filters.subject);
});
const getFirstSubjectCode = (nodes = []) => {
  for (const item of nodes) {
    if (item.code) return item.code;
    if (item.children && item.children.length > 0) {
      const childCode = getFirstSubjectCode(item.children);
      if (childCode) return childCode;
    }
  }
  return "";
};
const setDefaultSubjectSelection = async () => {
  const firstCode = getFirstSubjectCode(subjectOptions.value);
  if (!firstCode) {
    filters.subject = "";
    subjectTreeRef.value?.setCurrentKey(null);
    return;
  }
  filters.subject = firstCode;
  await nextTick();
  subjectTreeRef.value?.setCurrentKey(firstCode);
};
const filterSubjectNode = (value, data) => {
  const keyword = value?.trim();
  if (!keyword) return true;
  return `${data.code}${data.name}`.includes(keyword);
};
watch(subjectKeyword, (value) => {
  subjectTreeRef.value?.filter(value || "");
});
const handleSubjectClick = async (data) => {
  filters.subject = data.code;
  await getTableData();
};
const loadSubjectOptions = async () => {
  let options = [];
  try {
    const { data } = await listAccountSubject({
      current: 1,
      size: 1000,
      status: 0,
    });
    options = toTree(data?.records || []);
  } catch (error) {
    // å…¨å±€æ‹¦æˆªå™¨å·²æç¤ºï¼Œä¸‹é¢èµ°å…œåº•ç§‘ç›®
  }
  if (options.length === 0) {
    options = fallbackSubjects.map(item => ({ ...item, children: [] }));
  }
  subjectOptions.value = options;
  await setDefaultSubjectSelection();
  if (filters.subject) {
    await getTableData();
  }
};
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
// è”调约定:总账接口返回行数组(rowType/date/voucherNo/summary/debit/credit/direction/balance)
const getTableData = async () => {
  if (!currentSubject.value) {
    dataList.value = [];
    return;
  }
  try {
    const { data } = await getGeneralLedger({
      subjectCode: currentSubject.value.code,
      startMonth: filters.startMonth,
      endMonth: filters.endMonth,
    });
    dataList.value = Array.isArray(data) ? data : data?.records || [];
  } catch (error) {
    // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
  }
};
const resetFilters = async () => {
  filters.startMonth = defaultMonth;
  filters.endMonth = defaultMonth;
  dataList.value = [];
  subjectKeyword.value = "";
  subjectTreeRef.value?.filter("");
  await setDefaultSubjectSelection();
  if (filters.subject) {
    await getTableData();
  }
};
const handlePrint = () => {
  ElMessage.info("打印功能");
};
const handleOut = () => {
  ElMessage.success("导出成功");
};
onMounted(async () => {
  await loadSubjectOptions();
});
</script>
<style lang="scss" scoped>
.ledger-layout {
  display: flex;
  gap: 16px;
}
.subject-panel {
  width: 260px;
  flex-shrink: 0;
  padding: 12px;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  background-color: #fff;
}
.subject-tree-scroll {
  height: 600px;
  margin-top: 12px;
}
.subject-node {
  display: inline-flex;
  align-items: center;
}
.ledger-content {
  flex: 1;
  min-width: 0;
}
.filter-form {
  margin-bottom: 12px;
}
.text-primary {
  color: #409eff;
  font-weight: bold;
}
.text-success {
  color: #67c23a;
  font-weight: bold;
}
.text-danger {
  color: #f56c6c;
  font-weight: bold;
}
.text-warning {
  color: #e6a23c;
  font-weight: bold;
}
.subject-panel :deep(.el-tree-node__content) {
  height: 34px;
}
.subject-panel :deep(.el-tree-node.is-current > .el-tree-node__content) {
  background-color: #f0f7ff;
}
</style>
src/views/financialManagement/voucher/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1186 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="凭证字号:">
        <el-input v-model="filters.voucherNo" placeholder="请输入凭证字号" clearable style="width: 200px;" />
      </el-form-item>
      <el-form-item label="凭证日期:">
        <el-date-picker v-model="filters.dateRange" value-format="YYYY-MM-DD" format="YYYY-MM-DD" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
      </el-form-item>
      <el-form-item label="制单人:">
        <el-select v-model="filters.creator" placeholder="请选择制单人" clearable style="width: 150px;">
          <el-option
            v-for="item in creatorOptions"
            :key="item"
            :label="item"
            :value="item"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="状态:">
        <el-select v-model="filters.status" placeholder="请选择状态" clearable style="width: 150px;">
          <el-option label="未过账" value="unposted" />
          <el-option label="已过账" value="posted" />
          <el-option label="已作废" value="cancelled" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="getTableData">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div>
          <el-statistic title="借方合计" :value="totalDebit" :precision="2" prefix="Â¥" />
          <el-statistic title="贷方合计" :value="totalCredit" :precision="2" prefix="Â¥" style="margin-left: 30px;" />
        </div>
        <div>
          <el-button type="primary" @click="add" icon="Plus">新增凭证</el-button>
          <!-- <el-button @click="handleImport" icon="Upload">导入</el-button> -->
          <!-- <el-button @click="handleOut" icon="Download">导出</el-button> -->
        </div>
      </div>
      <PIMTable
        rowKey="id"
        :column="columns"
        :tableData="dataList"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @pagination="changePage"
      >
        <template #debit="{ row }">
          <span class="text-danger" v-if="row.debit > 0">Â¥{{ formatMoney(row.debit) }}</span>
          <span v-else>-</span>
        </template>
        <template #credit="{ row }">
          <span class="text-success" v-if="row.credit > 0">Â¥{{ formatMoney(row.credit) }}</span>
          <span v-else>-</span>
        </template>
        <template #status="{ row }">
          <el-tag :type="getStatusType(row.status)">{{ getStatusLabel(row.status) }}</el-tag>
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="primary" link @click="edit(row)" v-if="canEditVoucher(row.status)">编辑</el-button>
          <el-button type="success" link @click="handlePost(row)" v-if="canEditVoucher(row.status)">过账</el-button>
          <el-button type="danger" link @click="handleCancel(row)" v-if="canEditVoucher(row.status)">作废</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDialog :title="dialogTitle" v-model="dialogVisible" width="1200px" @confirm="submitForm" @cancel="dialogVisible = false">
      <div class="voucher-container">
        <div class="voucher-header">
          <h2 class="voucher-title">记账凭证</h2>
          <div class="voucher-period">{{ form.voucherDate ? form.voucherDate.substring(0, 7) + '期' : '' }}</div>
        </div>
        <el-form :model="form" :rules="rules" :disabled="isViewMode" ref="formRef" label-width="0">
          <div class="voucher-info">
            <div class="voucher-no-section">
              <span class="label">凭证字:</span>
              <el-select v-model="form.voucherPrefix" :disabled="isViewMode" style="width: 70px;">
                <el-option label="è®°" value="è®°" />
                <el-option label="现" value="现" />
                <el-option label="银" value="银" />
                <el-option label="转" value="转" />
                <el-option label="收" value="收" />
                <el-option label="付" value="付" />
              </el-select>
              <el-input v-model="form.voucherNum" :disabled="isViewMode" style="width: 60px;" />
              <span class="label" style="margin-left: 5px;">号</span>
            </div>
            <div class="voucher-date-section">
              <span class="label">日期:</span>
              <el-date-picker v-model="form.voucherDate" :disabled="isViewMode" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 140px;" />
            </div>
            <div class="voucher-attachment-section">
              <span class="label">附件:</span>
              <el-input-number v-model="form.attachmentCount" :disabled="isViewMode" :min="0" :controls="false" style="width: 60px;" />
              <span class="label" style="margin-left: 5px;">å¼ </span>
            </div>
          </div>
          <div class="voucher-table">
            <table class="accounting-voucher">
              <thead>
                <tr>
                  <th class="col-summary" rowspan="2">摘要</th>
                  <th class="col-subject" rowspan="2">会计科目</th>
                  <th class="col-debit-header" colspan="11">借方</th>
                  <th class="col-credit-header" colspan="11">贷方</th>
                  <th class="col-action" rowspan="2">操作</th>
                </tr>
                <tr class="amount-header">
                  <th>亿</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>万</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>元</th>
                  <th>角</th>
                  <th>分</th>
                  <th>亿</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>万</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>元</th>
                  <th>角</th>
                  <th>分</th>
                </tr>
              </thead>
              <tbody>
                <tr v-for="(entry, rowIndex) in form.entries" :key="rowIndex" @click="selectRow(rowIndex)" :class="{ 'selected-row': selectedRowIndex === rowIndex }">
                  <td class="col-summary">
                    <el-input v-model="entry.summary" :disabled="isViewMode" placeholder="请输入摘要" @focus="selectRow(rowIndex)" />
                  </td>
                  <td class="col-subject">
                    <el-tree-select
                      v-model="entry.subjectCode"
                      :data="subjectTreeOptions"
                      :props="subjectTreeSelectProps"
                      :disabled="isViewMode"
                      placeholder="选择科目"
                      filterable
                      check-strictly
                      clearable
                      :render-after-expand="false"
                      @change="(val) => handleSubjectChange(val, rowIndex)"
                      @focus="selectRow(rowIndex)"
                    />
                    <!-- <div class="subject-name">{{ entry.subjectName }}</div> -->
                  </td>
                  <!-- å€Ÿæ–¹11列 -->
                  <template v-if="editingCell.row === rowIndex && editingCell.type === 'debit'">
                    <td colspan="11" class="debit-input-cell">
                      <el-input-number ref="amountInputRef" v-model="entry.debit" :disabled="isViewMode" :min="0" :precision="2" :controls="false" :value-on-clear="undefined" size="small" @blur="finishEdit" class="full-width-input" />
                    </td>
                  </template>
                  <template v-else>
                    <td v-for="(digit, dIndex) in getAmountDigits(entry.debit, 11)" :key="'debit-'+dIndex" class="amount-cell debit-cell" @click="openAmountInput(rowIndex, 'debit')">
                      <span :class="{ 'text-primary': digit !== '', 'zero': digit === '' }">{{ digit || '' }}</span>
                    </td>
                  </template>
                  <!-- è´·æ–¹11列 -->
                  <template v-if="editingCell.row === rowIndex && editingCell.type === 'credit'">
                    <td colspan="11" class="credit-input-cell">
                      <el-input-number ref="amountInputRef" v-model="entry.credit" :disabled="isViewMode" :min="0" :precision="2" :controls="false" :value-on-clear="undefined" size="small" @blur="finishEdit" class="full-width-input" />
                    </td>
                  </template>
                  <template v-else>
                    <td v-for="(digit, dIndex) in getAmountDigits(entry.credit, 11)" :key="'credit-'+dIndex" class="amount-cell credit-cell" @click="openAmountInput(rowIndex, 'credit')">
                      <span :class="{ 'text-danger': digit !== '', 'zero': digit === '' }">{{ digit || '' }}</span>
                    </td>
                  </template>
                  <td class="col-action">
                    <el-button type="danger" link size="small" @click="removeEntry(rowIndex)" icon="Delete" :disabled="isViewMode || form.entries.length <= 2">删除</el-button>
                  </td>
                </tr>
                <tr class="total-row">
                  <td class="col-summary" colspan="2" style="text-align: center; font-weight: bold;">合计:</td>
                  <td v-for="(digit, index) in getAmountDigits(totalDebitEntry, 11)" :key="'total-debit-'+index" class="amount-cell total-debit-cell">
                    <span :class="{ 'text-primary': digit !== '' }">{{ digit }}</span>
                  </td>
                  <td v-for="(digit, index) in getAmountDigits(totalCreditEntry, 11)" :key="'total-credit-'+index" class="amount-cell total-credit-cell">
                    <span :class="{ 'text-danger': digit !== '' }">{{ digit }}</span>
                  </td>
                  <td class="col-action"></td>
                </tr>
              </tbody>
            </table>
          </div>
          <div class="voucher-toolbar">
            <el-button type="primary" link @click="addEntry" icon="Plus" :disabled="isViewMode">新增行</el-button>
          </div>
          <div class="voucher-footer">
            <div class="creator-section">
              <span class="label">制单人:</span>
              <el-select
                v-model="form.creator"
                :disabled="isViewMode"
                placeholder="请选择制单人"
                filterable
                clearable
                style="width: 200px;"
              >
                <el-option
                  v-for="item in creatorOptions"
                  :key="item"
                  :label="item"
                  :value="item"
                />
              </el-select>
            </div>
          </div>
          <!-- é™„件材料 -->
          <div class="voucher-attachment-section">
            <div class="attachment-label">附件材料:</div>
            <el-upload
              v-model:file-list="form.attachments"
              :action="upload.url"
              multiple
              ref="fileUpload"
              auto-upload
              :headers="upload.headers"
              :before-upload="handleBeforeUpload"
              :on-error="handleUploadError"
              :on-success="handleUploadSuccess"
              :on-remove="handleRemove"
              :on-preview="handlePreview"
              :disabled="isViewMode">
              <el-button type="primary" v-if="!isViewMode">上传</el-button>
              <template #tip v-if="!isViewMode">
                <div class="el-upload__tip">
                  æ–‡ä»¶æ ¼å¼æ”¯æŒ doc,docx,xls,xlsx,ppt,pptx,pdf,txt,xml,jpg,jpeg,png,gif,bmp,rar,zip,7z
                </div>
              </template>
            </el-upload>
          </div>
        </el-form>
      </div>
      <template #footer>
        <div>
          <el-button v-if="!isViewMode" type="primary" @click="submitForm" :disabled="!isBalanced">保存</el-button>
          <el-button @click="dialogVisible = false">{{ isViewMode ? '关闭' : '取消' }}</el-button>
        </div>
      </template>
    </FormDialog>
    <!-- æ–‡ä»¶é¢„览组件 -->
    <FilePreview ref="filePreviewRef" />
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import FilePreview from "@/components/filePreview/index.vue";
import download from "@/plugins/download.js";
import { getToken } from "@/utils/auth";
import useUserStore from "@/store/modules/user";
import { userListNoPageByTenantId } from "@/api/system/user";
import { listAccountSubject } from "@/api/financialManagement/accountSubject";
import {
  listVoucherPage,
  addVoucher,
  updateVoucher,
  postVoucher,
  cancelVoucher,
  getVoucherDetail,
} from "@/api/financialManagement/voucher";
defineOptions({
  name: "凭证管理",
});
const userStore = useUserStore();
const getDefaultCreator = () => userStore.nickName || userStore.name || "张三";
const filters = reactive({
  voucherNo: "",
  dateRange: [],
  creator: "",
  status: "",
});
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
const columns = [
  { label: "凭证字号", prop: "voucherNo", width: "120" },
  { label: "凭证日期", prop: "voucherDate", width: "120" },
  { label: "摘要", prop: "summary", showOverflowTooltip: true },
  { label: "借方金额", prop: "debit", dataType: "slot", slot: "debit" },
  { label: "贷方金额", prop: "credit", dataType: "slot", slot: "credit" },
  { label: "制单人", prop: "creator", width: "100" },
  { label: "状态", prop: "status", dataType: "slot", slot: "status" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "220", fixed: "right" },
];
const dataList = ref([]);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const dialogMode = ref("add");
const isEdit = ref(false);
const currentId = ref(null);
const isViewMode = computed(() => dialogMode.value === "view");
const filePreviewRef = ref(null);
// ä¸Šä¼ ç›¸å…³é…ç½®
const upload = reactive({
  // ä¸Šä¼ çš„地址
  url: import.meta.env.VITE_APP_BASE_API + "/file/upload?type=14",
  // è®¾ç½®ä¸Šä¼ çš„请求头部
  headers: { Authorization: "Bearer " + getToken() },
});
const fileUpload = ref(null);
const fallbackSubjectTree = [
  { subjectCode: "1001", subjectName: "库存现金", balanceDirection: "借方", children: [] },
  { subjectCode: "1002", subjectName: "银行存款", balanceDirection: "借方", children: [] },
  { subjectCode: "1122", subjectName: "应收账款", balanceDirection: "借方", children: [] },
  { subjectCode: "2202", subjectName: "应付账款", balanceDirection: "贷方", children: [] },
  { subjectCode: "5001", subjectName: "生产成本", balanceDirection: "借方", children: [] },
  { subjectCode: "6001", subjectName: "主营业务收入", balanceDirection: "贷方", children: [] },
  { subjectCode: "6401", subjectName: "主营业务成本", balanceDirection: "借方", children: [] },
];
const subjectTreeOptions = ref([]);
const subjectList = ref([]);
const subjectTreeSelectProps = {
  children: "children",
  label: "label",
  value: "value",
};
const buildSubjectTreeOptions = (nodes = [], flatList = []) =>
  (nodes || [])
    .filter(item => item.subjectCode && item.subjectName)
    .map(item => {
      const balanceDirection = item.balanceDirection || "";
      const flatItem = {
        code: item.subjectCode,
        name: item.subjectName,
        balanceDirection,
      };
      flatList.push(flatItem);
      return {
        value: flatItem.code,
        label: `${flatItem.code} ${flatItem.name}${balanceDirection ? ` [${balanceDirection}]` : ""}`,
        children: buildSubjectTreeOptions(item.children || [], flatList),
      };
    });
const createEmptyEntry = () => ({
  subjectCode: "",
  subjectName: "",
  balanceDirection: "",
  summary: "",
  debit: undefined,
  credit: undefined,
});
const createDefaultForm = () => ({
  voucherNo: "",
  voucherPrefix: "è®°",
  voucherNum: "",
  voucherDate: "",
  attachmentCount: 0,
  attachments: [],
  entries: [createEmptyEntry(), createEmptyEntry()],
  creator: getDefaultCreator(),
  remark: "",
});
const form = reactive({
  ...createDefaultForm(),
});
const userOptions = ref([]);
const creatorOptions = computed(() => {
  const source = [
    ...userOptions.value.map(item => item.nickName || item.userName || item.name),
    getDefaultCreator(),
    form.creator,
    filters.creator,
  ];
  return [...new Set(source.filter(Boolean))];
});
const selectedRowIndex = ref(-1);
const editingCell = reactive({
  row: -1,
  type: "",
});
const amountInputRef = ref(null);
const isBalanced = computed(() => {
  return totalDebitEntry.value === totalCreditEntry.value && totalDebitEntry.value > 0;
});
const rules = {
  voucherDate: [{ required: true, message: "请选择凭证日期", trigger: "change" }],
};
const totalDebit = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.debit), 0);
});
const totalCredit = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.credit), 0);
});
const totalDebitEntry = computed(() => {
  return form.entries.reduce((sum, item) => sum + Number(item.debit || 0), 0);
});
const totalCreditEntry = computed(() => {
  return form.entries.reduce((sum, item) => sum + Number(item.credit || 0), 0);
});
const formatMoney = (value) => {
  if (value === undefined || value === null) return "0.00";
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const normalizeVoucherStatus = status => String(status || "").toLowerCase();
const canEditVoucher = status => {
  const key = normalizeVoucherStatus(status);
  return key === "unposted" || status === "未过账";
};
const getStatusLabel = (status) => {
  const key = normalizeVoucherStatus(status);
  const map = { unposted: "未过账", posted: "已过账", cancelled: "已作废" };
  return map[key] || status;
};
const getStatusType = (status) => {
  const key = normalizeVoucherStatus(status);
  const map = { unposted: "warning", posted: "success", cancelled: "info" };
  return map[key] || "";
};
// è”调约定:分页参数使用 current/size,日期范围拆分为 startDate/endDate
const getTableData = async () => {
  try {
    const [startDate, endDate] =
      filters.dateRange && filters.dateRange.length === 2 ? filters.dateRange : ["", ""];
    const { data } = await listVoucherPage({
      current: pagination.currentPage,
      size: pagination.pageSize,
      voucherNo: filters.voucherNo,
      creator: filters.creator,
      status: filters.status,
      startDate,
      endDate,
    });
    dataList.value = data?.records || [];
    pagination.total = Number(data?.total || 0);
  } catch (error) {
    // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
  }
};
// å‡­è¯åˆ†å½•里的科目下拉与总账科目保持一致,避免提交不存在科目
const loadSubjectList = async () => {
  try {
    const { data } = await listAccountSubject({
      current: 1,
      size: 1000,
      status: 0
    });
    const flatList = [];
    const treeOptions = buildSubjectTreeOptions(data?.records || [], flatList);
    if (treeOptions.length > 0) {
      subjectTreeOptions.value = treeOptions;
      subjectList.value = flatList;
      return;
    }
    const fallbackFlatList = [];
    subjectTreeOptions.value = buildSubjectTreeOptions(fallbackSubjectTree, fallbackFlatList);
    subjectList.value = fallbackFlatList;
  } catch (error) {
    // å…¨å±€æ‹¦æˆªå™¨å·²æç¤ºé”™è¯¯ï¼Œè¿™é‡Œä¿ç•™é»˜è®¤ç§‘目作为兜底
    const fallbackFlatList = [];
    subjectTreeOptions.value = buildSubjectTreeOptions(fallbackSubjectTree, fallbackFlatList);
    subjectList.value = fallbackFlatList;
  }
};
const loadUserOptions = async () => {
  try {
    const { data } = await userListNoPageByTenantId();
    userOptions.value = Array.isArray(data) ? data : [];
  } catch (error) {
    userOptions.value = [];
  }
};
const resetFilters = () => {
  filters.voucherNo = "";
  filters.dateRange = [];
  filters.creator = "";
  filters.status = "";
  pagination.currentPage = 1;
  getTableData();
};
const changePage = ({ current, size }) => {
  pagination.currentPage = current;
  pagination.pageSize = size;
  getTableData();
};
const addEntry = () => {
  if (isViewMode.value) {
    return;
  }
  form.entries.push(createEmptyEntry());
};
const handleAttachmentChange = (fileList) => {
  form.attachmentCount = fileList?.length || 0;
};
// ä½¿ç”¨é¡¹ç›®å°è£…çš„ filePreview ç»„件预览文件
const previewFile = (row) => {
  const url = row.previewURL || row.previewUrl || row.url;
  if (url && filePreviewRef.value) {
    filePreviewRef.value.open(url);
  } else {
    ElMessage.warning('文件地址无效,无法预览');
  }
};
// ä½¿ç”¨é¡¹ç›®å°è£…çš„ download æ’件下载文件
const downloadFile = (row) => {
  const url = row.downloadURL || row.downloadUrl || row.url;
  if (url) {
    const filename = row.originalFilename || row.name || row.fileName || 'download';
    download.byUrl(url, filename);
  } else {
    ElMessage.warning('文件地址无效,无法下载');
  }
};
const selectRow = (index) => {
  selectedRowIndex.value = index;
};
const openAmountInput = (index, type) => {
  if (isViewMode.value) {
    return;
  }
  editingCell.row = index;
  editingCell.type = type;
  nextTick(() => {
    if (amountInputRef.value) {
      amountInputRef.value.focus();
    }
  });
};
const finishEdit = () => {
  editingCell.row = -1;
  editingCell.type = "";
};
const getAmountDigits = (amount, length) => {
  if (!amount || amount === 0) {
    return new Array(length).fill('');
  }
  const amountStr = Number(amount).toFixed(2);
  const [intPart, decPart] = amountStr.split('.');
  const fullAmount = intPart + decPart;
  // å·¦å¡«å……0到指定长度
  const paddedAmount = fullAmount.padStart(length, '0');
  const digits = paddedAmount.split('');
  // æ‰¾åˆ°ç¬¬ä¸€ä¸ªéžé›¶æ•°å­—的位置
  let firstNonZeroIndex = 0;
  for (let i = 0; i < digits.length; i++) {
    if (digits[i] !== '0') {
      firstNonZeroIndex = i;
      break;
    }
  }
  // åªéšè—å‰å¯¼é›¶ï¼ˆç¬¬ä¸€ä¸ªéžé›¶æ•°å­—之前的零)
  return digits.map((d, index) => {
    if (index < firstNonZeroIndex) {
      return ''; // å‰å¯¼é›¶æ˜¾ç¤ºä¸ºç©º
    }
    return d; // ä¿ç•™é‡‘额中的零
  });
};
const removeEntry = (index) => {
  if (isViewMode.value) {
    return;
  }
  if (form.entries.length <= 2) {
    return;
  }
  form.entries.splice(index, 1);
};
const handleSubjectChange = (val, index) => {
  const subject = subjectList.value.find(item => item.code === val);
  if (subject) {
    form.entries[index].subjectName = subject.name;
    form.entries[index].balanceDirection = subject.balanceDirection || "";
  } else {
    form.entries[index].subjectName = "";
    form.entries[index].balanceDirection = "";
  }
};
const add = () => {
  dialogMode.value = "add";
  isEdit.value = false;
  currentId.value = null;
  dialogTitle.value = "新增凭证";
  const nextNum = String((pagination.total || 0) + 1).padStart(4, "0");
  Object.assign(form, createDefaultForm(), {
    voucherPrefix: "è®°",
    voucherNum: nextNum,
    voucherNo: `è®°-${nextNum}`,
    voucherDate: new Date().toISOString().split('T')[0],
  });
  selectedRowIndex.value = 0;
  dialogVisible.value = true;
};
const openVoucherDialog = async (row, mode = "edit") => {
  try {
    dialogMode.value = mode;
    isEdit.value = mode === "edit";
    currentId.value = row.id;
    dialogTitle.value = mode === "view" ? "查看凭证" : "编辑凭证";
    const { data } = await getVoucherDetail(row.id);
    const detail = data || row;
    const parts = (detail.voucherNo || "").split("-");
    const attachments = detail.salesLedgerFiles || detail.storageBlobVOList || detail.storageBlobDTOs || detail.attachments || [];
    Object.assign(form, createDefaultForm(), {
      ...detail,
      voucherPrefix: parts[0] || "è®°",
      voucherNum: parts[1] || "",
      creator: detail.creator || getDefaultCreator(),
      attachments,
      entries:
        detail.entries?.map(item => ({
          subjectCode: item.subjectCode || "",
          subjectName: item.subjectName || "",
          balanceDirection: item.balanceDirection || "",
          summary: item.summary || "",
          debit: Number(item.debit || 0),
          credit: Number(item.credit || 0),
        })) || [],
    });
    if (form.entries.length < 2) {
      while (form.entries.length < 2) {
        form.entries.push(createEmptyEntry());
      }
    }
    selectedRowIndex.value = 0;
    dialogVisible.value = true;
  } catch (error) {
    // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
  }
};
const edit = async row => {
  await openVoucherDialog(row, "edit");
};
const view = async row => {
  await openVoucherDialog(row, "view");
};
const handlePost = (row) => {
  ElMessageBox.confirm("确认过账该凭证吗?", "提示", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "info",
  }).then(async () => {
    await postVoucher({ id: row.id });
    ElMessage.success("过账成功");
    await getTableData();
  });
};
const handleCancel = (row) => {
  ElMessageBox.confirm("确认作废该凭证吗?", "提示", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  }).then(async () => {
    await cancelVoucher({ id: row.id });
    ElMessage.success("作废成功");
    await getTableData();
  });
};
// æ–‡ä»¶ä¸Šä¼ å‰æ ¡éªŒ
const handleBeforeUpload = (file) => {
  const allowedTypes = [
    'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt', 'xml',
    'jpg', 'jpeg', 'png', 'gif', 'bmp', 'rar', 'zip', '7z'
  ];
  const fileExt = file.name.split('.').pop().toLowerCase();
  const isAllowed = allowedTypes.includes(fileExt);
  const isLt50M = file.size / 1024 / 1024 < 50;
  if (!isAllowed) {
    ElMessage.error('文件格式不支持!');
    return false;
  }
  if (!isLt50M) {
    ElMessage.error('文件大小不能超过 50MB!');
    return false;
  }
  return true;
};
// æ–‡ä»¶ä¸Šä¼ æˆåŠŸ
const handleUploadSuccess = (response, file, fileList) => {
  if (response.code === 200) {
    ElMessage.success('上传成功');
    // æ›´æ–°é™„件列表,使用后端返回的数据
    const index = form.attachments.findIndex(item => item.uid === file.uid);
    if (index !== -1) {
      const resData = response.data || {};
      form.attachments[index] = {
        ...form.attachments[index],
        ...resData,
        name: resData.originalFilename || resData.fileName || file.name,
        url: resData.url || resData.previewUrl || resData.downloadUrl,
        tempId: resData.tempId || resData.id
      };
    }
  } else {
    ElMessage.error(response.msg || '上传失败');
  }
};
// æ–‡ä»¶ä¸Šä¼ å¤±è´¥
const handleUploadError = (error, file, fileList) => {
  ElMessage.error('文件上传失败,请重试');
  console.error('上传失败:', error);
};
// æ–‡ä»¶ç§»é™¤
const handleRemove = (file, fileList) => {
  form.attachments = fileList;
  ElMessage.success('文件已移除');
};
// æ–‡ä»¶é¢„览
const handlePreview = (file) => {
  const fileData = {
    name: file.name || file.originalFilename || file.fileName,
    url: file.url || file.previewUrl || file.downloadUrl,
    id: file.id || file.tempId
  };
  if (filePreviewRef.value) {
    filePreviewRef.value.open(fileData);
  }
};
const handleImport = () => {
  ElMessage.info("导入功能");
};
const handleOut = () => {
  ElMessage.success("导出成功");
};
const submitForm = () => {
  if (isViewMode.value) {
    dialogVisible.value = false;
    return;
  }
  formRef.value.validate(async valid => {
    if (valid) {
      // å‰ç½®æ ¡éªŒï¼šä¸ŽåŽç«¯è§„则对齐,减少无效请求
      if (!isBalanced.value) {
        ElMessage.error("借贷不平衡,请检查分录");
        return;
      }
      const validEntries = form.entries.filter(
        entry => entry.subjectCode && (Number(entry.debit) > 0 || Number(entry.credit) > 0)
      );
      if (validEntries.length === 0) {
        ElMessage.error("请至少填写一条有效分录");
        return;
      }
      const invalidEntry = validEntries.find(
        entry => Number(entry.debit) > 0 && Number(entry.credit) > 0
      );
      if (invalidEntry) {
        ElMessage.error("同一分录不能同时填写借方和贷方");
        return;
      }
      const summary = validEntries.find(e => e.debit > 0)?.summary || "";
      // æå–附件的 tempFileIds
      const tempFileIds = (form.attachments || [])
        .filter(item => item.tempId || item.id)
        .map(item => item.tempId || item.id);
      const voucherNo = `${form.voucherPrefix}-${form.voucherNum}`;
      const dataToSave = {
        voucherNo,
        voucherDate: form.voucherDate,
        summary,
        creator: form.creator,
        attachmentCount: Number(form.attachmentCount || 0),
        remark: form.remark,
        debit: totalDebitEntry.value,
        credit: totalCreditEntry.value,
        tempFileIds: tempFileIds,
        entries: validEntries.map(entry => ({
          subjectCode: entry.subjectCode,
          subjectName: entry.subjectName,
          summary: entry.summary,
          debit: Number(entry.debit || 0),
          credit: Number(entry.credit || 0),
        })),
      };
      try {
        if (isEdit.value) {
          await updateVoucher({
            id: currentId.value,
            ...dataToSave,
          });
          ElMessage.success("编辑成功");
        } else {
          await addVoucher(dataToSave);
          ElMessage.success("新增成功");
        }
        dialogVisible.value = false;
        await getTableData();
      } catch (error) {
        // æç¤ºç”±å…¨å±€è¯·æ±‚拦截器处理,这里仅防止未捕获异常
      }
    }
  });
};
onMounted(async () => {
  await loadUserOptions();
  await loadSubjectList();
  await getTableData();
});
</script>
<style lang="scss" scoped>
.actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  > div:first-child {
    display: flex;
    align-items: center;
  }
}
.text-success {
  color: #67c23a;
  font-weight: bold;
}
.text-danger {
  color: #f56c6c;
  font-weight: bold;
}
.text-primary {
  color: #409eff;
}
.voucher-container {
  background: #fff;
  padding: 20px;
}
.voucher-header {
  text-align: center;
  margin-bottom: 15px;
  .voucher-title {
    font-size: 22px;
    font-weight: bold;
    margin: 0 0 5px 0;
    color: #303133;
  }
  .voucher-period {
    font-size: 14px;
    color: #909399;
  }
}
.voucher-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  padding: 0 10px;
  .label {
    font-size: 14px;
    color: #606266;
  }
  .voucher-no-section,
  .voucher-date-section,
  .voucher-attachment-section {
    display: flex;
    align-items: center;
  }
}
.voucher-attachment-section {
  margin-top: 15px;
  padding: 0 10px;
  .attachment-label {
    font-size: 14px;
    color: #606266;
    margin-bottom: 10px;
    font-weight: 500;
  }
  :deep(.el-upload-list) {
    max-height: 200px;
    overflow-y: auto;
  }
}
.voucher-table {
  border: 1px solid #dcdfe6;
  border-right: none;
  margin-bottom: 15px;
}
.accounting-voucher {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
  th,
  td {
    border: 1px solid #dcdfe6;
    text-align: center;
    padding: 0;
    height: 36px;
  }
  & th:last-child,
  & td:last-child {
    border-right: none !important;
  }
  thead {
    background-color: #f5f7fa;
    th {
      font-weight: normal;
      color: #606266;
      font-size: 12px;
    }
    .col-summary,
    .col-subject {
      font-weight: bold;
      font-size: 13px;
    }
    .col-debit-header,
    .col-credit-header {
      background-color: #ecf5ff;
      color: #409eff;
      font-weight: bold;
    }
  }
  .amount-header {
    th {
      font-size: 11px;
      padding: 2px 0;
      background-color: #f5f7fa;
    }
  }
  .col-summary {
    width: 160px;
    min-width: 160px;
  }
  .col-subject {
    width: 180px;
    min-width: 180px;
  }
  .col-action {
    width: 60px;
    min-width: 60px;
    text-align: center;
  }
  .amount-cell {
    width: 24px;
    min-width: 24px;
    max-width: 24px;
    padding: 0;
    font-size: 13px;
    font-family: 'Courier New', monospace;
    cursor: pointer;
    text-align: center;
    &:hover {
      background-color: #f5f7fa;
    }
    span {
      display: block;
      width: 100%;
      height: 100%;
      line-height: 36px;
      &.zero {
        color: #c0c4cc;
      }
    }
  }
  .debit-input-cell,
  .credit-input-cell {
    padding: 0;
    background-color: #ecf5ff;
    .full-width-input {
      width: 100%;
      :deep(.el-input__wrapper) {
        padding: 0 10px;
        box-shadow: none;
        background-color: transparent;
      }
      input {
        text-align: right;
        font-size: 14px;
        height: 34px;
      }
    }
  }
  tbody {
    tr {
      &:hover {
        background-color: #f5f7fa;
      }
      &.selected-row {
        background-color: #ecf5ff;
      }
    }
    td {
      .el-input {
        .el-input__wrapper {
          box-shadow: none;
          padding: 0 5px;
        }
        input {
          text-align: center;
          height: 34px;
        }
      }
      .el-select {
        width: 100%;
        .el-input__wrapper {
          box-shadow: none;
        }
        input {
          text-align: center;
          height: 34px;
        }
      }
    }
    .col-summary {
      .el-input input {
        text-align: left;
        padding-left: 10px;
      }
    }
    .col-subject {
      position: relative;
      .el-select,
      .el-tree-select {
        .el-input input {
          font-size: 12px;
        }
      }
      .subject-name {
        font-size: 11px;
        color: #909399;
        margin-top: 2px;
        line-height: 1.2;
      }
    }
  }
  .total-row {
    background-color: #fdf6ec;
    td {
      font-weight: bold;
    }
    .total-cell {
      background-color: #fdf6ec;
      font-weight: bold;
    }
  }
}
.voucher-toolbar {
  display: flex;
  justify-content: flex-start;
  padding: 10px 0;
  margin-top: 5px;
}
.voucher-footer {
  display: flex;
  justify-content: flex-end;
  padding: 0 10px;
  margin-top: 10px;
  .creator-section {
    .label {
      font-size: 14px;
      color: #606266;
    }
  }
}
:deep(.el-dialog__body) {
  padding: 10px 20px;
}
</style>