Forráskód Böngészése

出入库增加选择日期批量导出

xiongxing 2 hete
szülő
commit
5243014132

+ 296 - 0
src/views/warehouses/batchExportSheet.ts

@@ -0,0 +1,296 @@
+import * as XLSX from 'xlsx-js-style'
+
+/** 入库批量导出的行数据(一行对应一条入库明细) */
+export interface MaterialsInExportRow {
+  /** 入库单号 */
+  orderNo?: string
+  /** 入库日期 yyyy-MM-dd */
+  orderDate?: string
+  /** 供应商 */
+  supplierName?: string
+  /** 备注 */
+  remark?: string
+  /** 仓库 */
+  storeName?: string
+  /** 物资类别 */
+  categoryName?: string
+  /** 物资编码 */
+  materialNumber?: string
+  /** 物资名称 */
+  materialName?: string
+  /** 计量单位 */
+  materialUnit?: string
+  /** 生产日期 yyyy-MM-dd */
+  produceDate?: string
+  /** 入库数量 */
+  inQty?: number | string
+  /** 入库单价 */
+  inUnitPrice?: number | string
+  /** 金额 */
+  amount?: number | string
+}
+
+/** 出库批量导出的行数据(一行对应一条出库明细) */
+export interface MaterialsOutExportRow {
+  /** 出库单号 */
+  orderNo?: string
+  /** 出库日期 yyyy-MM-dd */
+  orderDate?: string
+  /** 领用部门 */
+  outDeptName?: string
+  /** 领用人 */
+  outUser?: string
+  /** 出库原因 */
+  outReason?: string
+  /** 备注 */
+  remark?: string
+  /** 仓库 */
+  storeName?: string
+  /** 关联入库单号 */
+  refInOrderNo?: string
+  /** 物资编码 */
+  materialNumber?: string
+  /** 物资名称 */
+  materialName?: string
+  /** 计量单位 */
+  materialUnit?: string
+  /** 生产日期 yyyy-MM-dd */
+  produceDate?: string
+  /** 出库数量 */
+  quantity?: number | string
+  /** 入库单价 */
+  inUnitPrice?: number | string
+  /** 出库单价 */
+  saleUnitPrice?: number | string
+  /** 金额 */
+  amount?: number | string
+}
+
+/** 入库明细表头 */
+const IN_HEADER = [
+  '入库单号',
+  '入库日期',
+  '供应商',
+  '备注',
+  '仓库',
+  '物资类别',
+  '物资编码',
+  '物资名称',
+  '计量单位',
+  '生产日期',
+  '入库数量',
+  '入库单价',
+  '金额'
+]
+const IN_COLUMN_WIDTH = [
+  { wch: 38 },
+  { wch: 12 },
+  { wch: 34 },
+  { wch: 16 },
+  { wch: 16 },
+  { wch: 16 },
+  { wch: 14 },
+  { wch: 30 },
+  { wch: 10 },
+  { wch: 12 },
+  { wch: 10 },
+  { wch: 10 },
+  { wch: 10 }
+]
+/** 数量 / 单价 / 金额列下标(导出为数值,便于 Excel 直接统计) */
+const IN_NUMBER_COL = [10, 11, 12]
+/** 左对齐的文本列下标 */
+const IN_LEFT_COL = [0, 2, 7]
+
+/** 出库明细表头 */
+const OUT_HEADER = [
+  '出库单号',
+  '出库日期',
+  '领用部门',
+  '领用人',
+  '出库原因',
+  '备注',
+  '仓库',
+  '关联入库单号',
+  '物资编码',
+  '物资名称',
+  '计量单位',
+  '生产日期',
+  '出库数量',
+  '入库单价',
+  '出库单价',
+  '金额'
+]
+const OUT_COLUMN_WIDTH = [
+  { wch: 38 },
+  { wch: 12 },
+  { wch: 16 },
+  { wch: 12 },
+  { wch: 14 },
+  { wch: 16 },
+  { wch: 16 },
+  { wch: 38 },
+  { wch: 14 },
+  { wch: 30 },
+  { wch: 10 },
+  { wch: 12 },
+  { wch: 10 },
+  { wch: 10 },
+  { wch: 10 },
+  { wch: 10 }
+]
+const OUT_NUMBER_COL = [12, 13, 14, 15]
+const OUT_LEFT_COL = [0, 2, 3, 5, 7, 9]
+
+const FULL_BORDER = {
+  top: { style: 'thin', color: { rgb: 'FF000000' } },
+  bottom: { style: 'thin', color: { rgb: 'FF000000' } },
+  left: { style: 'thin', color: { rgb: 'FF000000' } },
+  right: { style: 'thin', color: { rgb: 'FF000000' } }
+}
+
+const HEADER_STYLE = {
+  fill: { patternType: 'solid', fgColor: { rgb: 'FFA6A6A6' } },
+  font: { sz: 11, bold: true, color: { rgb: 'FF000000' }, name: '宋体' },
+  alignment: { vertical: 'center', horizontal: 'center', wrapText: true },
+  border: FULL_BORDER
+}
+
+/** 取 yyyy-MM-dd */
+const dateText = (d?: string) => (d ? String(d).slice(0, 10) : '')
+
+/** 数值转换:非数字返回空串 */
+const numText = (v?: number | string) => {
+  if (v === null || v === undefined || v === '') return ''
+  const num = Number(v)
+  return Number.isNaN(num) ? '' : num
+}
+
+const strText = (v?: string | number) => (v === null || v === undefined ? '' : String(v))
+
+const toInRow = (row: MaterialsInExportRow): (string | number)[] => [
+  strText(row.orderNo),
+  dateText(row.orderDate),
+  strText(row.supplierName),
+  strText(row.remark),
+  strText(row.storeName),
+  strText(row.categoryName),
+  strText(row.materialNumber),
+  strText(row.materialName),
+  strText(row.materialUnit),
+  dateText(row.produceDate),
+  numText(row.inQty),
+  numText(row.inUnitPrice),
+  numText(row.amount)
+]
+
+const toOutRow = (row: MaterialsOutExportRow): (string | number)[] => [
+  strText(row.orderNo),
+  dateText(row.orderDate),
+  strText(row.outDeptName),
+  strText(row.outUser),
+  strText(row.outReason),
+  strText(row.remark),
+  strText(row.storeName),
+  strText(row.refInOrderNo),
+  strText(row.materialNumber),
+  strText(row.materialName),
+  strText(row.materialUnit),
+  dateText(row.produceDate),
+  numText(row.quantity),
+  numText(row.inUnitPrice),
+  numText(row.saleUnitPrice),
+  numText(row.amount)
+]
+
+/**
+ * 生成明细表:表头带底色 + 全表边框,数量/单价/金额为数值列
+ * @param header 表头
+ * @param rows 数据行
+ * @param columnWidth 列宽
+ * @param numberCols 数值列下标
+ * @param leftCols 左对齐列下标
+ * @param fileName 文件名(不带后缀)
+ * @param sheetName 工作表名
+ */
+const writeDetailSheet = (
+  header: string[],
+  rows: (string | number)[][],
+  columnWidth: { wch: number }[],
+  numberCols: number[],
+  leftCols: number[],
+  fileName: string,
+  sheetName: string
+) => {
+  const aoa: (string | number)[][] = [header, ...rows]
+
+  const ws = XLSX.utils.aoa_to_sheet(aoa)
+  ws['!cols'] = columnWidth
+  ws['!rows'] = aoa.map(() => ({ hpt: 22 }))
+
+  const range = XLSX.utils.decode_range(ws['!ref'] || 'A1')
+  for (let R = range.s.r; R <= range.e.r; R++) {
+    for (let C = range.s.c; C <= range.e.c; C++) {
+      const cellRef = XLSX.utils.encode_cell({ r: R, c: C })
+      if (!ws[cellRef]) ws[cellRef] = { v: '', t: 's' }
+      const cell: Recordable = ws[cellRef]
+      if (R === 0) {
+        cell.s = HEADER_STYLE
+        continue
+      }
+      cell.s = {
+        font: { sz: 11, name: '宋体' },
+        alignment: {
+          vertical: 'center',
+          horizontal: numberCols.includes(C) ? 'right' : leftCols.includes(C) ? 'left' : 'center',
+          wrapText: true
+        },
+        border: FULL_BORDER
+      }
+      // 保障数量/单价/金额为数值类型
+      if (numberCols.includes(C) && typeof cell.v === 'number') {
+        cell.t = 'n'
+      }
+    }
+  }
+
+  const wb = XLSX.utils.book_new()
+  XLSX.utils.book_append_sheet(wb, ws, sheetName)
+  XLSX.writeFile(wb, `${fileName}.xlsx`)
+}
+
+/**
+ * 导出物资入库明细(一行一条物资)
+ */
+export const exportMaterialsInBatch = (
+  rows: MaterialsInExportRow[],
+  fileName = '入库单明细'
+) => {
+  writeDetailSheet(
+    IN_HEADER,
+    (Array.isArray(rows) ? rows : []).map(toInRow),
+    IN_COLUMN_WIDTH,
+    IN_NUMBER_COL,
+    IN_LEFT_COL,
+    fileName,
+    '入库单明细'
+  )
+}
+
+/**
+ * 导出物资出库明细(一行一条物资)
+ */
+export const exportMaterialsOutBatch = (
+  rows: MaterialsOutExportRow[],
+  fileName = '出库单明细'
+) => {
+  writeDetailSheet(
+    OUT_HEADER,
+    (Array.isArray(rows) ? rows : []).map(toOutRow),
+    OUT_COLUMN_WIDTH,
+    OUT_NUMBER_COL,
+    OUT_LEFT_COL,
+    fileName,
+    '出库单明细'
+  )
+}

+ 148 - 0
src/views/warehouses/materialsIn/index.vue

@@ -41,6 +41,7 @@
       <div class="mb-4">
         <el-button v-hasPermi="['warehouses:materialsIn:add']" type="primary" plain @click="handleAdd"><Icon icon="ep:plus" class="mr-5px" />新增</el-button>
         <el-button v-hasPermi="['warehouses:materialsIn:import']" type="success" @click="openImport"><Icon icon="ep:download" class="mr-5px" />导入</el-button>
+        <el-button v-hasPermi="['warehouses:materialsIn:export']" type="primary" plain @click="openBatchExport"><Icon icon="ep:upload" class="mr-5px" />批量导出</el-button>
       </div>
 
       <el-table :data="tableData" v-loading="loading" :header-cell-style="tableHeaderColor">
@@ -95,6 +96,31 @@
     <Print ref="printRef" />
     <!-- 导入弹窗(复用 system/user 的 ImportFile 组件) -->
     <Import ref="importRef" :config="importConfig" @success="handleImportSuccess" />
+
+    <!-- 批量导出弹窗 -->
+    <el-dialog v-model="batchExportVisible" title="批量导出" width="460px" append-to-body>
+      <el-form label-width="90px">
+        <el-form-item label="导出日期">
+          <el-date-picker
+            v-model="batchExportRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            :unlink-panels="true"
+            class="!w-full"
+          />
+        </el-form-item>
+      </el-form>
+      <div class="text-12px text-gray-500">
+        默认导出本月数据,导出所选日期范围内的入库单明细(一条物资一行)。
+      </div>
+      <template #footer>
+        <el-button @click="batchExportVisible = false">取消</el-button>
+        <el-button type="primary" :loading="batchExportLoading" @click="handleBatchExport">导出</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -110,6 +136,7 @@ import download from '@/utils/download'
 import Import from '@/components/ImportFile/index.vue'
 import { handleTree } from '@/utils/tree'
 import * as DeptApi from '@/api/system/dept'
+import { exportMaterialsInBatch } from '../batchExportSheet'
 
 const message = useMessage()
 
@@ -188,6 +215,127 @@ const handleImportSuccess = () => {
   getTableData()
 }
 
+/** 导出日期转字符串 */
+const formatDateStr = (date) => {
+  const y = date.getFullYear()
+  const m = String(date.getMonth() + 1).padStart(2, '0')
+  const d = String(date.getDate()).padStart(2, '0')
+  return `${y}-${m}-${d}`
+}
+
+/** 默认导出本月:本月 1 号 ~ 今天 */
+const currentMonthRange = () => {
+  const now = new Date()
+  return [`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`, formatDateStr(now)]
+}
+
+// 批量导出
+const BATCH_EXPORT_PAGE_SIZE = 10000
+const batchExportVisible = ref(false)
+const batchExportLoading = ref(false)
+const batchExportRange = ref(currentMonthRange())
+
+const openBatchExport = () => {
+  batchExportRange.value = currentMonthRange()
+  batchExportVisible.value = true
+}
+
+/** 按导出日期拉取入库单(pageSize 取大值,并按 total 兜底翻页) */
+const fetchInboundOrders = async (orderDate) => {
+  const all = []
+  let pageNo = 1
+  let total = 0
+  while (pageNo <= 50) {
+    const res = await materialIoPage({
+      pageNo,
+      pageSize: BATCH_EXPORT_PAGE_SIZE,
+      tenantIds: queryParams.tenantIds?.length ? queryParams.tenantIds : undefined,
+      bizType: 'IN',
+      orderDate,
+    })
+    const list = res?.list || []
+    all.push(...list)
+    total = res?.total || 0
+    // 已取完(或后端单页有上限时继续翻页)
+    if (!list.length || list.length < BATCH_EXPORT_PAGE_SIZE || all.length >= total) break
+    pageNo++
+  }
+  return all
+}
+
+/** 列表未返回明细时,按单号补查详情 */
+const fetchOrderItems = async (orders) => {
+  const itemsMap = new Map()
+  const needIds = orders.filter((order) => !(order.items || []).length && order.id).map((order) => order.id)
+  const CHUNK_SIZE = 5
+  let failed = false
+  for (let i = 0; i < needIds.length; i += CHUNK_SIZE) {
+    if (failed) break
+    const chunk = needIds.slice(i, i + CHUNK_SIZE)
+    const details = await Promise.all(
+      chunk.map(async (id) => {
+        try {
+          return await getMaterialIoInboundDetail(id)
+        } catch {
+          failed = true
+          return null
+        }
+      })
+    )
+    chunk.forEach((id, index) => itemsMap.set(id, details[index]?.items || []))
+  }
+  return itemsMap
+}
+
+/** 批量导出 */
+const handleBatchExport = async () => {
+  const range = batchExportRange.value
+  if (!range || range.length !== 2) {
+    ElMessage.warning('请选择导出日期')
+    return
+  }
+  batchExportLoading.value = true
+  try {
+    const orders = await fetchInboundOrders(range)
+    if (!orders.length) {
+      ElMessage.warning('所选日期范围内没有入库单数据')
+      return
+    }
+    const itemsMap = await fetchOrderItems(orders)
+    const rows = []
+    orders.forEach((order) => {
+      const items = (order.items || []).length ? order.items : itemsMap.get(order.id) || []
+      items.forEach((item) => {
+        rows.push({
+          orderNo: order.orderNo,
+          orderDate: order.orderDate,
+          supplierName: item.supplierName || order.supplierName,
+          remark: order.remark,
+          storeName: item.refInStoreName || item.storeName,
+          categoryName: item.categoryName || item.materialCategoryName,
+          materialNumber: item.materialNumber,
+          materialName: item.materialName,
+          materialUnit: item.materialUnit,
+          produceDate: item.produceDate,
+          inQty: item.inQty ?? item.quantity,
+          inUnitPrice: item.inUnitPrice,
+          amount: item.amount,
+        })
+      })
+    })
+    if (!rows.length) {
+      ElMessage.warning('所选日期范围内的入库单没有明细数据')
+      return
+    }
+    exportMaterialsInBatch(rows, `入库单明细_${range[0]}_${range[1]}`)
+    batchExportVisible.value = false
+  } catch (e) {
+    ElMessage.error(e?.message || '导出失败')
+  } finally {
+    batchExportLoading.value = false
+  }
+}
+
 const getMaterialOptions = async () => {
   try {
     const res = await materialList({ 

+ 152 - 0
src/views/warehouses/materialsOut/index.vue

@@ -41,6 +41,7 @@
       <div class="mb-4">
         <el-button v-hasPermi="['warehouses:materialsOut:add']" type="primary" plain @click="handleAdd"><Icon icon="ep:plus" class="mr-5px" />新增</el-button>
         <el-button v-hasPermi="['warehouses:materialsOut:import']" type="success" @click="openImport"><Icon icon="ep:download" class="mr-5px" />导入</el-button>
+        <el-button v-hasPermi="['warehouses:materialsOut:export']" type="primary" plain @click="openBatchExport"><Icon icon="ep:upload" class="mr-5px" />批量导出</el-button>
       </div>
 
       <el-table :data="tableData" v-loading="loading" :header-cell-style="tableHeaderColor">
@@ -88,6 +89,31 @@
     <Print ref="printRef" />
     <!-- 导入弹窗(复用 system/user 的 ImportFile 组件) -->
     <Import ref="importRef" :config="importConfig" @success="handleImportSuccess" />
+
+    <!-- 批量导出弹窗 -->
+    <el-dialog v-model="batchExportVisible" title="批量导出" width="460px" append-to-body>
+      <el-form label-width="90px">
+        <el-form-item label="导出日期">
+          <el-date-picker
+            v-model="batchExportRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            :unlink-panels="true"
+            class="!w-full"
+          />
+        </el-form-item>
+      </el-form>
+      <div class="text-12px text-gray-500">
+        默认导出本月数据,导出所选日期范围内的出库单明细(一条物资一行)。
+      </div>
+      <template #footer>
+        <el-button @click="batchExportVisible = false">取消</el-button>
+        <el-button type="primary" :loading="batchExportLoading" @click="handleBatchExport">导出</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -101,6 +127,7 @@ import * as DeptApi from '@/api/system/dept'
 import { getStrDictOptions, DICT_TYPE } from '@/utils/dict'
 import download from '@/utils/download'
 import Import from '@/components/ImportFile/index.vue'
+import { exportMaterialsOutBatch } from '../batchExportSheet'
 
 
 const MaterialsOutDialog = defineAsyncComponent(() => import('./components/MaterialsOutDialog.vue'))
@@ -348,6 +375,131 @@ const handleExport = async(row) => {
   }
 }
 
+/** 导出日期转字符串 */
+const formatDateStr = (date) => {
+  const y = date.getFullYear()
+  const m = String(date.getMonth() + 1).padStart(2, '0')
+  const d = String(date.getDate()).padStart(2, '0')
+  return `${y}-${m}-${d}`
+}
+
+/** 默认导出本月:本月 1 号 ~ 今天 */
+const currentMonthRange = () => {
+  const now = new Date()
+  return [`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`, formatDateStr(now)]
+}
+
+// 批量导出
+const BATCH_EXPORT_PAGE_SIZE = 10000
+const batchExportVisible = ref(false)
+const batchExportLoading = ref(false)
+const batchExportRange = ref(currentMonthRange())
+
+const openBatchExport = () => {
+  batchExportRange.value = currentMonthRange()
+  batchExportVisible.value = true
+}
+
+/** 按导出日期拉取出库单(pageSize 取大值,并按 total 兜底翻页) */
+const fetchOutboundOrders = async (orderDate) => {
+  const all = []
+  let pageNo = 1
+  let total = 0
+  while (pageNo <= 50) {
+    const res = await materialIoPage({
+      pageNo,
+      pageSize: BATCH_EXPORT_PAGE_SIZE,
+      tenantIds: queryParams.tenantIds?.length ? queryParams.tenantIds : undefined,
+      bizType: 'OUT',
+      orderDate,
+    })
+    const list = res?.list || []
+    all.push(...list)
+    total = res?.total || 0
+    // 已取完(或后端单页有上限时继续翻页)
+    if (!list.length || list.length < BATCH_EXPORT_PAGE_SIZE || all.length >= total) break
+    pageNo++
+  }
+  return all
+}
+
+/** 列表未返回明细时,按单号补查详情 */
+const fetchOrderItems = async (orders) => {
+  const itemsMap = new Map()
+  const needIds = orders.filter((order) => !(order.items || []).length && order.id).map((order) => order.id)
+  const CHUNK_SIZE = 5
+  let failed = false
+  for (let i = 0; i < needIds.length; i += CHUNK_SIZE) {
+    if (failed) break
+    const chunk = needIds.slice(i, i + CHUNK_SIZE)
+    const details = await Promise.all(
+      chunk.map(async (id) => {
+        try {
+          return await getMaterialOutboundDetail(id)
+        } catch {
+          failed = true
+          return null
+        }
+      })
+    )
+    chunk.forEach((id, index) => itemsMap.set(id, details[index]?.items || []))
+  }
+  return itemsMap
+}
+
+/** 批量导出 */
+const handleBatchExport = async () => {
+  const range = batchExportRange.value
+  if (!range || range.length !== 2) {
+    ElMessage.warning('请选择导出日期')
+    return
+  }
+  batchExportLoading.value = true
+  try {
+    const orders = await fetchOutboundOrders(range)
+    if (!orders.length) {
+      ElMessage.warning('所选日期范围内没有出库单数据')
+      return
+    }
+    const itemsMap = await fetchOrderItems(orders)
+    const rows = []
+    orders.forEach((order) => {
+      const items = (order.items || []).length ? order.items : itemsMap.get(order.id) || []
+      items.forEach((item) => {
+        rows.push({
+          orderNo: order.orderNo,
+          orderDate: order.orderDate,
+          outDeptName: order.outDeptName,
+          outUser: order.outUser,
+          outReason: order.outReason,
+          remark: order.remark,
+          storeName: item.refInStoreName || item.storeName,
+          refInOrderNo: item.refInOrderNo,
+          materialNumber: item.materialNumber,
+          materialName: item.materialName,
+          materialUnit: item.materialUnit,
+          produceDate: item.produceDate,
+          quantity: item.quantity ?? item.outQty,
+          inUnitPrice: item.inUnitPrice,
+          saleUnitPrice: item.saleUnitPrice,
+          amount: item.amount,
+        })
+      })
+    })
+    if (!rows.length) {
+      ElMessage.warning('所选日期范围内的出库单没有明细数据')
+      return
+    }
+    exportMaterialsOutBatch(rows, `出库单明细_${range[0]}_${range[1]}`)
+    batchExportVisible.value = false
+  } catch (e) {
+    console.error(e)
+    ElMessage.error(e?.message || '导出失败')
+  } finally {
+    batchExportLoading.value = false
+  }
+}
+
 // 删除
 const handleDelete = (row) => {
   ElMessageBox.confirm(`确定要删除入库单号为 "${row.orderNo}" 的入库单吗?`, '提示', {