zhou zhou
6 天以前 34d36a15f339d331d668d4063cfdff50cffa5800
#导出服务
20个文件已添加
46个文件已修改
3510 ■■■■■ 已修改文件
asrs-schedule/pom.xml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/common/config/MybatisPlusConfig.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/AsnOrderLogSchedule.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/AsnOrderPressureSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/AutoRunSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/CheckOrderSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/ExportTaskCleanupSchedules.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/PakinSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/ScheduleJobs.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/SynchronizationToMESSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/TaskCacheLocSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/TaskMissionSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/TaskSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/WaveSchedules.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/entity/ExportTask.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/mapper/ExportTaskMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/service/ExportTaskService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/service/impl/ExportTaskServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/src/main/resources/application.yml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/in-statistic-item.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/in-statistic.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/loc.js 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/out-statistic-item.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/out-statistic.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/statistic-count.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/system-manage.js 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/warehouse-areas-item.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/biz/list-export-print/index.vue 70 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/en.json 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/zh.json 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/adapters/backendMenuAdapter.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/utils/backend-menu-title.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/loc/index.vue 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/reports/statistic-count/index.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/reports/statistic-count/statisticCountPage.helpers.js 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/in-statistic-item/inStatisticItemPage.helpers.js 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/in-statistic-item/index.vue 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/in-statistic/inStatisticPage.helpers.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/in-statistic/index.vue 115 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/out-statistic-item/index.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/out-statistic-item/outStatisticItemPage.helpers.js 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/out-statistic/index.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/statistics/out-statistic/outStatisticPage.helpers.js 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/stock/warehouse-areas-item/index.vue 181 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/export-task/exportTaskPage.helpers.js 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/export-task/exportTaskTable.columns.js 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/export-task/index.vue 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/role/modules/role-permission-dialog.vue 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/common/service/AsyncListExportTaskService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/common/service/ListExportService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/common/service/impl/AsyncListExportTaskServiceImpl.java 225 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/common/utils/ExcelUtil.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocController.java 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemDeadController.java 116 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/StockStatisticController.java 237 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/WarehouseAreasItemController.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ExportTaskController.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/entity/ExportTask.java 127 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/ExportTaskMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/service/ExportTaskService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/ExportTaskServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/sql/20260417_export_task.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/sql/20260417_export_task_expire_time.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/sql/20260417_export_task_menu.sql 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/sql/20260417_loc_export_menu.sql 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/sql/20260417_warehouse_areas_item_export_menu.sql 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
asrs-schedule/pom.xml
@@ -77,6 +77,7 @@
                        <nonFilteredFileExtension>xls</nonFilteredFileExtension>
                        <nonFilteredFileExtension>xlsx</nonFilteredFileExtension>
                        <nonFilteredFileExtension>zip</nonFilteredFileExtension>
                        <nonFilteredFileExtension>jar</nonFilteredFileExtension>
                    </nonFilteredFileExtensions>
                </configuration>
            </plugin>
asrs-schedule/src/main/java/com/vincent/rsf/schedule/common/config/MybatisPlusConfig.java
@@ -60,6 +60,7 @@
                        "sys_user_role",
                        "sys_role_menu",
                        "sys_menu",
                        "sys_export_task",
                        "sys_pda_role_menu",
                        "sys_menu_pda",
                        "sys_matnr_role_menu",
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/AsnOrderLogSchedule.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/AsnOrderPressureSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.exception.CoolException;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/AutoRunSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.vincent.rsf.framework.common.SpringUtils;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/CheckOrderSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.exception.CoolException;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/ExportTaskCleanupSchedules.java
New file
@@ -0,0 +1,68 @@
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.schedule.system.entity.ExportTask;
import com.vincent.rsf.schedule.system.service.ExportTaskService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.File;
import java.util.Date;
import java.util.List;
import java.util.Objects;
@Slf4j
@Component
public class ExportTaskCleanupSchedules {
    private final ExportTaskService exportTaskService;
    @Value("${rsf.export-task.cleanup-batch-size:200}")
    private int cleanupBatchSize;
    public ExportTaskCleanupSchedules(ExportTaskService exportTaskService) {
        this.exportTaskService = exportTaskService;
    }
    @Scheduled(
            initialDelayString = "${rsf.export-task.cleanup-initial-delay-ms:60000}",
            fixedDelayString = "${rsf.export-task.cleanup-fixed-delay-ms:3600000}"
    )
    public void cleanupExpiredExportTasks() {
        List<ExportTask> expiredTasks = exportTaskService.list(
                new LambdaQueryWrapper<ExportTask>()
                        .eq(ExportTask::getDeleted, 0)
                        .isNotNull(ExportTask::getExpireTime)
                        .lt(ExportTask::getExpireTime, new Date())
                        .last("LIMIT " + Math.max(cleanupBatchSize, 1))
        );
        if (Cools.isEmpty(expiredTasks)) {
            return;
        }
        expiredTasks.forEach(this::deleteTaskFile);
        exportTaskService.removeByIds(expiredTasks.stream().map(ExportTask::getId).toList());
    }
    private void deleteTaskFile(ExportTask task) {
        if (task == null || Cools.isEmpty(task.getFilePath())) {
            return;
        }
        try {
            File file = new File(task.getFilePath());
            if (file.exists() && file.isFile() && !file.delete()) {
                log.warn("导出任务文件删除失败, taskId={}, filePath={}", task.getId(), task.getFilePath());
            }
        } catch (Exception error) {
            log.warn(
                    "导出任务文件删除异常, taskId={}, filePath={}",
                    task.getId(),
                    Objects.toString(task.getFilePath(), ""),
                    error
            );
        }
    }
}
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/PakinSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/ScheduleJobs.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/SynchronizationToMESSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/TaskCacheLocSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.common.Cools;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/TaskMissionSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.common.Cools;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/TaskSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/schedules/WaveSchedules.java
@@ -1,4 +1,4 @@
package com.vincent.rsf.schedule.manager.schedules;
package com.vincent.rsf.schedule.schedules;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/entity/ExportTask.java
New file
@@ -0,0 +1,34 @@
package com.vincent.rsf.schedule.system.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("sys_export_task")
public class ExportTask implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "文件路径")
    private String filePath;
    @ApiModelProperty(value = "过期时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date expireTime;
    @ApiModelProperty(value = "删除标记")
    private Integer deleted;
}
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/mapper/ExportTaskMapper.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.schedule.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.schedule.system.entity.ExportTask;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface ExportTaskMapper extends BaseMapper<ExportTask> {
}
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/service/ExportTaskService.java
New file
@@ -0,0 +1,7 @@
package com.vincent.rsf.schedule.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.schedule.system.entity.ExportTask;
public interface ExportTaskService extends IService<ExportTask> {
}
asrs-schedule/src/main/java/com/vincent/rsf/schedule/system/service/impl/ExportTaskServiceImpl.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.schedule.system.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.schedule.system.entity.ExportTask;
import com.vincent.rsf.schedule.system.mapper.ExportTaskMapper;
import com.vincent.rsf.schedule.system.service.ExportTaskService;
import org.springframework.stereotype.Service;
@Service("exportTaskService")
public class ExportTaskServiceImpl extends ServiceImpl<ExportTaskMapper, ExportTask> implements ExportTaskService {
}
asrs-schedule/src/main/resources/application.yml
@@ -34,6 +34,12 @@
  file:
    path: logs/@pom.artifactId@
rsf:
  export-task:
    cleanup-batch-size: 200
    cleanup-initial-delay-ms: 60000
    cleanup-fixed-delay-ms: 3600000
# 下位机配置
wcs-slave:
  agv: false
rsf-design/src/api/in-statistic-item.js
@@ -12,6 +12,19 @@
  return Number.isFinite(numericValue) ? numericValue : fallback
}
function normalizeIds(ids) {
  if (Array.isArray(ids)) {
    return ids
      .map((id) => String(id).trim())
      .filter(Boolean)
      .join(',')
  }
  if (ids === null || ids === undefined) {
    return ''
  }
  return String(ids).trim()
}
function filterParams(params = {}, ignoredKeys = []) {
  return Object.fromEntries(
    Object.entries(params)
@@ -47,3 +60,20 @@
    url: `/stockStatistic/${id}`
  })
}
export function fetchGetInStatisticItemMany(ids) {
  return request.post({
    url: `/inStatisticItem/many/${normalizeIds(ids)}`
  })
}
export async function fetchExportInStatisticItemReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/inStatisticItem/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
rsf-design/src/api/in-statistic.js
@@ -12,6 +12,19 @@
  return Number.isFinite(numericValue) ? numericValue : fallback
}
function normalizeIds(ids) {
  if (Array.isArray(ids)) {
    return ids
      .map((id) => String(id).trim())
      .filter(Boolean)
      .join(',')
  }
  if (ids === null || ids === undefined) {
    return ''
  }
  return String(ids).trim()
}
function filterParams(params = {}, ignoredKeys = []) {
  return Object.fromEntries(
    Object.entries(params)
@@ -41,3 +54,20 @@
    params: buildInStatisticPageParams(params)
  })
}
export function fetchGetInStatisticMany(ids) {
  return request.post({
    url: `/inStatistic/many/${normalizeIds(ids)}`
  })
}
export async function fetchExportInStatisticReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/inStatistic/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
rsf-design/src/api/loc.js
@@ -206,3 +206,28 @@
    body: JSON.stringify(payload)
  })
}
export function fetchCreateLocExportTask(payload = {}) {
  return request.post({
    url: '/loc/export/async',
    params: payload
  })
}
export function fetchLocExportTask(taskId) {
  return request.get({
    url: `/loc/export/task/${String(taskId).trim()}`
  })
}
export async function fetchDownloadLocExportTask(taskId, options = {}) {
  return fetch(
    `${import.meta.env.VITE_API_URL}/loc/export/task/${String(taskId).trim()}/download`,
    {
      method: 'GET',
      headers: {
        ...(options.headers || {})
      }
    }
  )
}
rsf-design/src/api/out-statistic-item.js
@@ -12,6 +12,19 @@
  return Number.isFinite(numericValue) ? numericValue : fallback
}
function normalizeIds(ids) {
  if (Array.isArray(ids)) {
    return ids
      .map((id) => String(id).trim())
      .filter(Boolean)
      .join(',')
  }
  if (ids === null || ids === undefined) {
    return ''
  }
  return String(ids).trim()
}
function filterParams(params = {}, ignoredKeys = []) {
  return Object.fromEntries(
    Object.entries(params)
@@ -41,3 +54,20 @@
    params: buildOutStatisticItemPageParams(params)
  })
}
export function fetchGetOutStatisticItemMany(ids) {
  return request.post({
    url: `/outStatisticItem/many/${normalizeIds(ids)}`
  })
}
export async function fetchExportOutStatisticItemReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/outStatisticItem/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
rsf-design/src/api/out-statistic.js
@@ -12,6 +12,19 @@
  return Number.isFinite(numericValue) ? numericValue : fallback
}
function normalizeIds(ids) {
  if (Array.isArray(ids)) {
    return ids
      .map((id) => String(id).trim())
      .filter(Boolean)
      .join(',')
  }
  if (ids === null || ids === undefined) {
    return ''
  }
  return String(ids).trim()
}
function filterParams(params = {}, ignoredKeys = []) {
  return Object.fromEntries(
    Object.entries(params)
@@ -41,3 +54,20 @@
    params: buildOutStatisticPageParams(params)
  })
}
export function fetchGetOutStatisticMany(ids) {
  return request.post({
    url: `/outStatistic/many/${normalizeIds(ids)}`
  })
}
export async function fetchExportOutStatisticReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/outStatistic/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
rsf-design/src/api/statistic-count.js
@@ -4,6 +4,19 @@
  return typeof value === 'string' ? value.trim() : value
}
function normalizeIds(ids) {
  if (Array.isArray(ids)) {
    return ids
      .map((id) => String(id).trim())
      .filter(Boolean)
      .join(',')
  }
  if (ids === null || ids === undefined) {
    return ''
  }
  return String(ids).trim()
}
export function buildStatisticCountPageParams(params = {}) {
  const entries = Object.entries(params).filter(([key, value]) => {
    if (['current', 'pageSize', 'size'].includes(key)) {
@@ -31,3 +44,20 @@
    params: buildStatisticCountPageParams(params)
  })
}
export function fetchGetStatisticCountMany(ids) {
  return request.post({
    url: `/statistic/num/many/${normalizeIds(ids)}`
  })
}
export async function fetchExportStatisticCountReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/statistic/num/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
rsf-design/src/api/system-manage.js
@@ -104,6 +104,19 @@
  }
}
export function buildExportTaskPageParams(params = {}) {
  return {
    current: params.current || 1,
    pageSize: params.pageSize || params.size || 20,
    ...(params.condition !== undefined ? { condition: params.condition } : {}),
    ...(params.resourceKey !== undefined ? { resourceKey: params.resourceKey } : {}),
    ...(params.status !== undefined ? { status: params.status } : {}),
    ...(params.timeStart !== undefined ? { timeStart: params.timeStart } : {}),
    ...(params.timeEnd !== undefined ? { timeEnd: params.timeEnd } : {}),
    ...(params.orderBy !== undefined ? { orderBy: params.orderBy } : {})
  }
}
export function buildDictDataPageParams(params = {}) {
  return {
    current: params.current || 1,
@@ -251,12 +264,20 @@
  return request.post({ url: '/dictType/page', params: buildDictTypePageParams(params) })
}
function fetchExportTaskPage(params = {}) {
  return request.post({ url: '/exportTask/page', params: buildExportTaskPageParams(params) })
}
function fetchDictDataPage(params = {}) {
  return request.post({ url: '/dictData/page', params: buildDictDataPageParams(params) })
}
function fetchGetDictTypeDetail(id) {
  return request.get({ url: `/dictType/${id}` })
}
function fetchGetExportTaskDetail(id) {
  return request.get({ url: `/exportTask/${id}` })
}
function fetchGetDictDataDetail(id) {
@@ -306,6 +327,15 @@
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
async function fetchDownloadExportTask(id, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/exportTask/${id}/download`, {
    method: 'GET',
    headers: {
      ...(options.headers || {})
    }
  })
}
@@ -958,6 +988,9 @@
  fetchSaveSerialRule,
  fetchUpdateSerialRule,
  fetchDeleteSerialRule,
  fetchExportTaskPage,
  fetchGetExportTaskDetail,
  fetchDownloadExportTask,
  fetchDictTypePage,
  fetchGetDictTypeDetail,
  fetchGetDictDataDetail,
rsf-design/src/api/warehouse-areas-item.js
@@ -6,7 +6,10 @@
function normalizeIds(ids) {
  if (Array.isArray(ids)) {
    return ids.map((id) => String(id).trim()).filter(Boolean).join(',')
    return ids
      .map((id) => String(id).trim())
      .filter(Boolean)
      .join(',')
  }
  if (ids === null || ids === undefined) {
    return ''
@@ -78,3 +81,28 @@
    body: JSON.stringify(payload)
  })
}
export function fetchCreateWarehouseAreasItemExportTask(payload = {}) {
  return request.post({
    url: '/warehouseAreasItem/export/async',
    params: payload
  })
}
export function fetchWarehouseAreasItemExportTask(taskId) {
  return request.get({
    url: `/warehouseAreasItem/export/task/${String(taskId).trim()}`
  })
}
export async function fetchDownloadWarehouseAreasItemExportTask(taskId, options = {}) {
  return fetch(
    `${import.meta.env.VITE_API_URL}/warehouseAreasItem/export/task/${String(taskId).trim()}/download`,
    {
      method: 'GET',
      headers: {
        ...(options.headers || {})
      }
    }
  )
}
rsf-design/src/components/biz/list-export-print/index.vue
@@ -1,7 +1,11 @@
<template>
  <ElSpace v-bind="attrs" wrap>
    <ElButton :disabled="disabled" @click="handleExport">{{ t('common.actions.export') }}</ElButton>
    <ElButton :disabled="disabled" @click="handlePrint">{{ t('common.actions.print') }}</ElButton>
    <ElButton v-if="canExport" :disabled="disabled" @click="handleExport">
      {{ t('common.actions.export') }}
    </ElButton>
    <ElButton v-if="canExport" :disabled="disabled" @click="handlePrint">
      {{ t('common.actions.print') }}
    </ElButton>
  </ElSpace>
  <ListPrintPreviewDialog
@@ -16,7 +20,10 @@
<script setup>
  import { computed, useAttrs } from 'vue'
  import { ElMessage } from 'element-plus'
  import { useI18n } from 'vue-i18n'
  import { useAuth } from '@/hooks/core/useAuth'
  import { useUserStore } from '@/store/modules/user'
  import ListPrintPreviewDialog from './list-print-preview-dialog.vue'
  import {
    buildListExportPayload,
@@ -32,6 +39,8 @@
  const attrs = useAttrs()
  const { t } = useI18n()
  const { hasAuth } = useAuth()
  const userStore = useUserStore()
  const props = defineProps({
    reportTitle: { type: String, default: '' },
@@ -43,16 +52,13 @@
    previewMaxRows: { type: Number, default: 50 },
    total: { type: Number, default: 0 },
    maxResults: { type: Number, default: 1000 },
    disabled: { type: Boolean, default: false }
    disabled: { type: Boolean, default: false },
    exportAuth: { type: [String, Array], default: '' },
    printAuth: { type: [String, Array], default: '' }
  })
  const emit = defineEmits(['export', 'print'])
  const normalizedMeta = computed(() =>
    buildReportStyleMeta({
      reportTitle: props.reportTitle,
      meta: props.previewMeta
    })
  )
  const normalizedMeta = computed(() => buildCurrentReportMeta())
  const normalizedPreviewColumns = computed(() =>
    buildPreviewColumns({
      columns: props.columns,
@@ -63,6 +69,29 @@
    type: Boolean,
    default: false
  })
  const canExport = computed(() => !props.exportAuth || hasAuth(props.exportAuth))
  const canPrint = computed(() => !props.printAuth || hasAuth(props.printAuth))
  const printLimit = computed(() => {
    const limit = Number(props.maxResults)
    return Number.isFinite(limit) && limit > 0 ? limit : 1000
  })
  function buildCurrentReportMeta() {
    const now = new Date()
    return buildReportStyleMeta({
      reportTitle: props.reportTitle,
      meta: {
        reportDate: now.toLocaleDateString('zh-CN'),
        printedAt: now.toLocaleString('zh-CN', { hour12: false }),
        operator:
          userStore.getUserInfo?.name ||
          userStore.getUserInfo?.nickname ||
          userStore.getUserInfo?.username ||
          '',
        ...props.previewMeta
      }
    })
  }
  const handleExport = () => {
    const payload = buildListExportPayload({
@@ -70,12 +99,33 @@
      selectedRows: props.selectedRows,
      queryParams: props.queryParams,
      columns: props.columns,
      meta: normalizedMeta.value
      meta: buildCurrentReportMeta()
    })
    emit('export', payload)
  }
  const getPrintRowCount = () => {
    const selectedCount = Array.isArray(props.selectedRows) ? props.selectedRows.length : 0
    if (selectedCount > 0) {
      return selectedCount
    }
    return Number(props.total) || 0
  }
  const validatePrintLimit = () => {
    const rowCount = getPrintRowCount()
    if (rowCount <= printLimit.value) {
      return true
    }
    ElMessage.warning(t('message.printExceedMaxRows', { maxRows: printLimit.value }))
    return false
  }
  const handlePrint = () => {
    if (!validatePrintLimit()) {
      return
    }
    const payload = buildListPrintPayload({
      selectedRows: props.selectedRows,
      queryParams: props.queryParams,
rsf-design/src/locales/langs/en.json
@@ -449,7 +449,8 @@
      "userLogin": "Login Logs",
      "role": "Role Manage",
      "userCenter": "User Center",
      "menu": "Menu Manage"
      "menu": "Menu Manage",
      "exportTask": "Export Tasks"
    }
  },
  "menu": {
@@ -496,6 +497,7 @@
    "qlyInspect": "QlyInspect",
    "qlyIsptItem": "qlyIsptItem",
    "dictType": "DictType",
    "exportTask": "Export Tasks",
    "dictData": "DictData",
    "companys": "Companys",
    "serialRuleItem": "SerialRuleItem",
@@ -672,6 +674,7 @@
    "requestTimeoutStopped": "Request timed out and waiting has stopped",
    "exportTimeoutStopped": "Export request timed out and waiting has stopped",
    "printTimeoutStopped": "Print data loading timed out and waiting has stopped",
    "printExceedMaxRows": "Printing more than {maxRows} rows is disabled to avoid browser memory issues. Please narrow the filters or use export instead.",
    "routeRenderFailedTitle": "Page failed to load",
    "routeRenderFailed": "The page failed to render. Please try again later.",
    "systemUpgradeTitle": "System Upgrade Notice",
rsf-design/src/locales/langs/zh.json
@@ -449,7 +449,8 @@
      "userLogin": "登录日志",
      "role": "角色管理",
      "userCenter": "个人中心",
      "menu": "菜单管理"
      "menu": "菜单管理",
      "exportTask": "导出任务"
    }
  },
  "menu": {
@@ -496,6 +497,7 @@
    "qlyInspect": "质检信息",
    "qlyIsptItem": "质检信息明细",
    "dictType": "数据字典",
    "exportTask": "导出任务",
    "dictData": "字典数据集",
    "companys": "往来企业",
    "serialRuleItem": "编码规则子表",
@@ -674,6 +676,7 @@
    "requestTimeoutStopped": "请求超时,已停止等待",
    "exportTimeoutStopped": "导出请求超时,已停止等待",
    "printTimeoutStopped": "打印数据加载超时,已停止等待",
    "printExceedMaxRows": "打印数据超过 {maxRows} 行,为避免浏览器内存不足,已禁止打印,请先缩小筛选范围或改用导出。",
    "routeRenderFailedTitle": "页面加载失败",
    "routeRenderFailed": "页面渲染失败,请稍后重试",
    "systemUpgradeTitle": "系统升级提示",
rsf-design/src/router/adapters/backendMenuAdapter.js
@@ -15,6 +15,7 @@
  menu: '/system/menu',
  config: '/system/config',
  dictType: '/system/dict-type',
  exportTask: '/system/export-task',
  fields: '/system/fields',
  fieldsItem: '/system/fields-item',
  whMat: '/basic-info/wh-mat',
rsf-design/src/utils/backend-menu-title.js
@@ -30,6 +30,7 @@
  'menu.deviceSite': '路径管理',
  'menu.dictData': '字典数据集',
  'menu.dictType': '数据字典',
  'menu.exportTask': '导出任务',
  'menu.fields': '扩展字段',
  'menu.fieldsItem': '扩展字段明细',
  'menu.flowInstance': '流程实例',
rsf-design/src/views/basic-info/loc/index.vue
@@ -33,8 +33,10 @@
              :preview-rows="previewRows"
              :preview-meta="resolvedPreviewMeta"
              :total="pagination.total"
              :disabled="loading"
              @export="handleExport"
              :disabled="loading || exportTaskLoading"
              export-auth="manager:loc:export"
              print-auth="list"
              @export="handleExportRequest"
              @print="handlePrint"
            />
          </ElSpace>
@@ -71,7 +73,7 @@
</template>
<script setup>
  import { computed, onMounted, ref } from 'vue'
  import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { useUserStore } from '@/store/modules/user'
  import { useAuth } from '@/hooks/core/useAuth'
@@ -84,9 +86,12 @@
  import { fetchWarehouseAreasList, fetchWarehouseList } from '@/api/warehouse-areas'
  import {
    fetchDeleteLoc,
    fetchCreateLocExportTask,
    fetchDownloadLocExportTask,
    fetchExportLocReport,
    fetchGetLocDetail,
    fetchGetLocMany,
    fetchLocExportTask,
    fetchLocPage,
    fetchLocTypeList,
    fetchSaveLoc,
@@ -117,6 +122,9 @@
  defineOptions({ name: 'Loc' })
  const EXPORT_SYNC_MAX_ROWS = 5000
  const EXPORT_TASK_POLL_INTERVAL = 3000
  const { hasAuth } = useAuth()
  const userStore = useUserStore()
@@ -127,7 +135,9 @@
  const warehouseOptions = ref([])
  const areaOptions = ref([])
  const locTypeOptions = ref([])
  const exportTaskLoading = ref(false)
  let handleDeleteAction = null
  let exportTaskTimer = null
  const reportTitle = LOC_REPORT_TITLE
  const reportQueryParams = computed(() => buildLocSearchParams(searchForm.value))
@@ -189,9 +199,13 @@
  ])
  async function fetchLocDetailById(id) {
    return guardRequestWithMessage(fetchGetLocDetail(id), {}, {
      timeoutMessage: '库位详情加载超时,已停止等待'
    })
    return guardRequestWithMessage(
      fetchGetLocDetail(id),
      {},
      {
        timeoutMessage: '库位详情加载超时,已停止等待'
      }
    )
  }
  async function openDetail(row) {
@@ -292,13 +306,17 @@
  }
  const resolvePrintRecords = async (payload) => {
    const response = Array.isArray(payload?.ids) && payload.ids.length > 0
      ? await fetchGetLocMany(payload.ids)
      : await fetchLocPage({
          ...reportQueryParams.value,
          current: 1,
          pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20
        })
    const response =
      Array.isArray(payload?.ids) && payload.ids.length > 0
        ? await fetchGetLocMany(payload.ids)
        : await fetchLocPage({
            ...reportQueryParams.value,
            current: 1,
            pageSize:
              Number(pagination.total) > 0
                ? Number(pagination.total)
                : Number(payload?.pageSize) || 20
          })
    return defaultResponseAdapter(response).records
  }
@@ -321,6 +339,129 @@
    buildPreviewRows: (records) => buildLocPrintRows(records),
    buildPreviewMeta: (rows) => buildPreviewDialogMeta(rows)
  })
  function clearExportTaskTimer() {
    if (exportTaskTimer) {
      clearTimeout(exportTaskTimer)
      exportTaskTimer = null
    }
  }
  function getExportRowCount(payload) {
    const selectedIds = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : []
    if (selectedIds.length > 0) {
      return selectedIds.length
    }
    return Number(pagination.total) || 0
  }
  function needsAsyncExport(payload) {
    return getExportRowCount(payload) > EXPORT_SYNC_MAX_ROWS
  }
  function scheduleExportTaskPoll(taskId) {
    clearExportTaskTimer()
    exportTaskTimer = setTimeout(() => {
      pollExportTask(taskId)
    }, EXPORT_TASK_POLL_INTERVAL)
  }
  function resolveDownloadFileName(task = {}, response) {
    const contentDisposition = response?.headers?.get('Content-Disposition') || ''
    const matchedPart = contentDisposition
      .split(';')
      .map((part) => part.trim())
      .find((part) => /^filename\*?=/i.test(part))
    if (matchedPart) {
      let fileName = matchedPart.replace(/^filename\*?=/i, '').trim()
      if (fileName.toLowerCase().startsWith("utf-8''")) {
        fileName = fileName.slice(7)
      }
      if (fileName.startsWith('"') && fileName.endsWith('"')) {
        fileName = fileName.slice(1, -1)
      }
      try {
        return decodeURIComponent(fileName)
      } catch {
        return fileName
      }
    }
    return task.fileName || 'loc.xlsx'
  }
  async function downloadExportTaskFile(task) {
    const response = await fetchDownloadLocExportTask(task.id, {
      headers: {
        Authorization: userStore.accessToken || ''
      }
    })
    if (!response.ok) {
      throw new Error(`导出文件下载失败(${response.status})`)
    }
    const blob = await response.blob()
    const downloadUrl = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = downloadUrl
    link.download = resolveDownloadFileName(task, response)
    document.body.appendChild(link)
    link.click()
    link.remove()
    window.URL.revokeObjectURL(downloadUrl)
  }
  async function pollExportTask(taskId) {
    try {
      const task = await fetchLocExportTask(taskId)
      const status = Number(task?.status)
      if (status === 2) {
        clearExportTaskTimer()
        await downloadExportTaskFile(task)
        exportTaskLoading.value = false
        ElMessage.success(
          `导出任务已完成,已开始下载${task?.rowCount ? `(${task.rowCount}行)` : ''}`
        )
        return
      }
      if (status === 3) {
        clearExportTaskTimer()
        exportTaskLoading.value = false
        ElMessage.error(task?.errorMsg || '导出任务执行失败')
        return
      }
      scheduleExportTaskPoll(taskId)
    } catch (error) {
      clearExportTaskTimer()
      exportTaskLoading.value = false
      ElMessage.error(error?.message || '查询导出任务状态失败')
    }
  }
  async function handleExportRequest(payload) {
    if (!needsAsyncExport(payload)) {
      await handleExport(payload)
      return
    }
    const exportRowCount = getExportRowCount(payload)
    exportTaskLoading.value = true
    clearExportTaskTimer()
    try {
      const task = await fetchCreateLocExportTask(payload)
      ElMessage.success(
        `本次导出共 ${exportRowCount} 行,已超过 ${EXPORT_SYNC_MAX_ROWS} 行,系统已自动切换为后台导出任务${task?.taskCode ? `(${task.taskCode})` : ''}`
      )
      if (!task?.id) {
        throw new Error('导出任务创建成功,但未返回任务ID')
      }
      scheduleExportTaskPoll(task.id)
    } catch (error) {
      exportTaskLoading.value = false
      clearExportTaskTimer()
      ElMessage.error(error?.message || '创建导出任务失败')
    }
  }
  const resolvedPreviewMeta = computed(() =>
    buildLocReportMeta({
@@ -348,9 +489,28 @@
  onMounted(async () => {
    await Promise.all([
      loadOptions(fetchWarehouseList, resolveLocWarehouseOptions, warehouseOptions, '仓库选项加载超时,已停止等待'),
      loadOptions(fetchWarehouseAreasList, resolveLocAreaOptions, areaOptions, '库区选项加载超时,已停止等待'),
      loadOptions(fetchLocTypeList, resolveLocTypeOptions, locTypeOptions, '库位类型选项加载超时,已停止等待')
      loadOptions(
        fetchWarehouseList,
        resolveLocWarehouseOptions,
        warehouseOptions,
        '仓库选项加载超时,已停止等待'
      ),
      loadOptions(
        fetchWarehouseAreasList,
        resolveLocAreaOptions,
        areaOptions,
        '库区选项加载超时,已停止等待'
      ),
      loadOptions(
        fetchLocTypeList,
        resolveLocTypeOptions,
        locTypeOptions,
        '库位类型选项加载超时,已停止等待'
      )
    ])
  })
  onBeforeUnmount(() => {
    clearExportTaskTimer()
  })
</script>
rsf-design/src/views/reports/statistic-count/index.vue
@@ -9,7 +9,24 @@
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="loadPageData" />
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="loadPageData">
        <template #left>
          <ListExportPrint
            class="inline-flex"
            :preview-visible="previewVisible"
            @update:previewVisible="handlePreviewVisibleChange"
            :report-title="reportTitle"
            :query-params="reportQueryParams"
            :columns="reportColumns"
            :preview-rows="previewRows"
            :preview-meta="previewMeta"
            :total="pagination.total"
            :disabled="loading"
            @export="handleExport"
            @print="handlePrint"
          />
        </template>
      </ArtTableHeader>
      <ArtTable
        :loading="loading"
@@ -25,18 +42,31 @@
<script setup>
  import { computed, onMounted, reactive, ref } from 'vue'
  import { useUserStore } from '@/store/modules/user'
  import { useTableColumns } from '@/hooks/core/useTableColumns'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchStatisticCountPage } from '@/api/statistic-count'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import {
    fetchExportStatisticCountReport,
    fetchGetStatisticCountMany,
    fetchStatisticCountPage
  } from '@/api/statistic-count'
  import {
    buildStatisticCountPrintRows,
    buildStatisticCountPageQueryParams,
    createStatisticCountSearchState,
    getStatisticCountReportColumns,
    STATISTIC_COUNT_REPORT_TITLE,
    normalizeStatisticCountRow
  } from './statisticCountPage.helpers'
  import { createStatisticCountTableColumns } from './statisticCountTable.columns'
  defineOptions({ name: 'StatisticCount' })
  const userStore = useUserStore()
  const reportTitle = STATISTIC_COUNT_REPORT_TITLE
  const loading = ref(false)
  const tableData = ref([])
  const searchForm = ref(createStatisticCountSearchState())
@@ -45,6 +75,8 @@
    size: 20,
    total: 0
  })
  const reportColumns = getStatisticCountReportColumns()
  const reportQueryParams = computed(() => buildStatisticCountPageQueryParams(searchForm.value))
  const searchItems = computed(() => [
    {
@@ -97,6 +129,45 @@
  const { columns, columnChecks } = useTableColumns(() => createStatisticCountTableColumns())
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchGetStatisticCountMany(payload.ids)).records
    }
    return defaultResponseAdapter(
      await fetchStatisticCountPage({
        ...reportQueryParams.value,
        current: 1,
        pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : 20
      })
    ).records
  }
  const {
    previewVisible,
    previewRows,
    previewMeta,
    handlePreviewVisibleChange,
    handleExport,
    handlePrint
  } = usePrintExportPage({
    downloadFileName: 'statistic-count-report.xlsx',
    requestExport: (payload) =>
      fetchExportStatisticCountReport(payload, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      }),
    resolvePrintRecords,
    buildPreviewRows: (records) => buildStatisticCountPrintRows(records),
    buildPreviewMeta: (rows) => ({
      reportTitle,
      reportDate: new Date().toLocaleDateString('zh-CN'),
      printedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length
    })
  })
  function updatePaginationState(response) {
    pagination.total = Number(response?.total || 0)
    pagination.current = Number(response?.current || pagination.current || 1)
rsf-design/src/views/reports/statistic-count/statisticCountPage.helpers.js
@@ -53,3 +53,22 @@
    outAnfme: Number(row.outAnfme || 0)
  }
}
export function getStatisticCountReportColumns() {
  return [
    { source: 'dayTime', label: '统计日期' },
    { source: 'count', label: '记录数', align: 'right' },
    { source: 'inAnfmeCount', label: '入库笔数', align: 'right' },
    { source: 'outAnfmeCount', label: '出库笔数', align: 'right' },
    { source: 'anfme', label: '总数量', align: 'right' },
    { source: 'inAnfme', label: '入库数量', align: 'right' },
    { source: 'outAnfme', label: '出库数量', align: 'right' }
  ]
}
export function buildStatisticCountPrintRows(records = []) {
  if (!Array.isArray(records)) {
    return []
  }
  return records.map((record) => normalizeStatisticCountRow(record))
}
rsf-design/src/views/statistics/in-statistic-item/inStatisticItemPage.helpers.js
@@ -1,6 +1,10 @@
import { getInStatisticTaskStatusMeta, getInStatisticTaskTypeMeta } from '../in-statistic/inStatisticPage.helpers.js'
import {
  getInStatisticTaskStatusMeta,
  getInStatisticTaskTypeMeta
} from '../in-statistic/inStatisticPage.helpers.js'
export const IN_STATISTIC_ITEM_PAGE_TITLE = '入库统计明细'
export const IN_STATISTIC_ITEM_REPORT_TITLE = '日入库明细查询'
function normalizeText(value) {
  return String(value ?? '').trim()
@@ -45,7 +49,9 @@
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
    Object.entries(searchParams).filter(
      ([, value]) => value !== '' && value !== void 0 && value !== null
    )
  )
}
@@ -67,7 +73,9 @@
    dayTimeText: normalizeText(record.dayTime || record.day_time || ''),
    taskTypeText: normalizeText(record.taskTypeText || record['taskType$'] || taskTypeMeta.text),
    taskTypeTagType: normalizeText(record.taskTypeTagType || taskTypeMeta.type) || 'info',
    taskStatusText: normalizeText(record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text),
    taskStatusText: normalizeText(
      record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text
    ),
    taskStatusTagType: normalizeText(record.taskStatusTagType || taskStatusMeta.type) || 'info',
    locCode: normalizeText(record.locCode || record.loc_code || ''),
    barcode: normalizeText(record.barcode || ''),
@@ -84,3 +92,29 @@
    memo: normalizeText(record.memo || '')
  }
}
export function getInStatisticItemReportColumns() {
  return [
    { source: 'dayTimeText', label: '统计日期' },
    { source: 'locCode', label: '库位' },
    { source: 'matnrCode', label: '物料编码' },
    { source: 'maktx', label: '物料名称' },
    { source: 'anfme', label: '数量', align: 'right' },
    { source: 'batch', label: '批次' },
    { source: 'unit', label: '单位' },
    { source: 'barcode', label: '托盘码' },
    { source: 'taskTypeText', label: '任务类型' },
    { source: 'taskStatusText', label: '任务状态' },
    { source: 'createByText', label: '创建人' },
    { source: 'createTimeText', label: '创建时间' },
    { source: 'updateByText', label: '更新人' },
    { source: 'updateTimeText', label: '更新时间' }
  ]
}
export function buildInStatisticItemPrintRows(records = []) {
  if (!Array.isArray(records)) {
    return []
  }
  return records.map((record) => normalizeInStatisticItemRow(record))
}
rsf-design/src/views/statistics/in-statistic-item/index.vue
@@ -1,8 +1,31 @@
<template>
  <div class="in-statistic-item-page art-full-height">
    <ArtSearchBar v-model="searchForm" :items="searchItems" :showExpand="true" @search="handleSearch" @reset="handleReset" />
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
      :showExpand="true"
      @search="handleSearch"
      @reset="handleReset"
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData" />
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
        <template #left>
          <ListExportPrint
            class="inline-flex"
            :preview-visible="previewVisible"
            @update:previewVisible="handlePreviewVisibleChange"
            :report-title="reportTitle"
            :query-params="reportQueryParams"
            :columns="reportColumns"
            :preview-rows="previewRows"
            :preview-meta="previewMeta"
            :total="pagination.total"
            :disabled="loading"
            @export="handleExport"
            @print="handlePrint"
          />
        </template>
      </ArtTableHeader>
      <ArtTable
        :loading="loading"
        :data="data"
@@ -18,31 +41,76 @@
<script setup>
  import { computed, ref } from 'vue'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { fetchInStatisticItemPage } from '@/api/in-statistic-item'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import {
    fetchExportInStatisticItemReport,
    fetchGetInStatisticItemMany,
    fetchInStatisticItemPage
  } from '@/api/in-statistic-item'
  import {
    buildInStatisticItemPrintRows,
    buildInStatisticItemPageQueryParams,
    buildInStatisticItemSearchParams,
    createInStatisticItemSearchState,
    getInStatisticItemPaginationKey,
    normalizeInStatisticItemRow
    getInStatisticItemReportColumns,
    normalizeInStatisticItemRow,
    IN_STATISTIC_ITEM_REPORT_TITLE
  } from './inStatisticItemPage.helpers'
  import { createInStatisticItemTableColumns } from './inStatisticItemTable.columns'
  import InStatisticItemDetailDrawer from './modules/in-statistic-item-detail-drawer.vue'
  defineOptions({ name: 'InStatisticItem' })
  const userStore = useUserStore()
  const reportTitle = IN_STATISTIC_ITEM_REPORT_TITLE
  const searchForm = ref(createInStatisticItemSearchState())
  const detailDrawerVisible = ref(false)
  const detailData = ref({})
  const reportColumns = getInStatisticItemReportColumns()
  const reportQueryParams = computed(() => buildInStatisticItemPageQueryParams(searchForm.value))
  const searchItems = computed(() => [
    { label: '关键字', key: 'condition', type: 'input', props: { clearable: true, placeholder: '请输入物料名称/编码/批次/库位' } },
    { label: '统计日期', key: 'dayTime', type: 'date', props: { clearable: true, type: 'date', valueFormat: 'YYYY-MM-DD' } },
    { label: '库位', key: 'locCode', type: 'input', props: { clearable: true, placeholder: '请输入库位' } },
    { label: '物料编码', key: 'matnrCode', type: 'input', props: { clearable: true, placeholder: '请输入物料编码' } },
    { label: '物料名称', key: 'maktx', type: 'input', props: { clearable: true, placeholder: '请输入物料名称' } },
    { label: '批次', key: 'batch', type: 'input', props: { clearable: true, placeholder: '请输入批次' } }
    {
      label: '关键字',
      key: 'condition',
      type: 'input',
      props: { clearable: true, placeholder: '请输入物料名称/编码/批次/库位' }
    },
    {
      label: '统计日期',
      key: 'dayTime',
      type: 'date',
      props: { clearable: true, type: 'date', valueFormat: 'YYYY-MM-DD' }
    },
    {
      label: '库位',
      key: 'locCode',
      type: 'input',
      props: { clearable: true, placeholder: '请输入库位' }
    },
    {
      label: '物料编码',
      key: 'matnrCode',
      type: 'input',
      props: { clearable: true, placeholder: '请输入物料编码' }
    },
    {
      label: '物料名称',
      key: 'maktx',
      type: 'input',
      props: { clearable: true, placeholder: '请输入物料名称' }
    },
    {
      label: '批次',
      key: 'batch',
      type: 'input',
      props: { clearable: true, placeholder: '请输入批次' }
    }
  ])
  function openDetail(row) {
@@ -70,10 +138,50 @@
      columnsFactory: () => createInStatisticItemTableColumns({ handleView: openDetail })
    },
    transform: {
      dataTransformer: (records) => (Array.isArray(records) ? records.map((item) => normalizeInStatisticItemRow(item)) : [])
      dataTransformer: (records) =>
        Array.isArray(records) ? records.map((item) => normalizeInStatisticItemRow(item)) : []
    }
  })
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchGetInStatisticItemMany(payload.ids)).records
    }
    return defaultResponseAdapter(
      await fetchInStatisticItemPage({
        ...reportQueryParams.value,
        current: 1,
        pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : 20
      })
    ).records
  }
  const {
    previewVisible,
    previewRows,
    previewMeta,
    handlePreviewVisibleChange,
    handleExport,
    handlePrint
  } = usePrintExportPage({
    downloadFileName: 'in-statistic-item-report.xlsx',
    requestExport: (payload) =>
      fetchExportInStatisticItemReport(payload, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      }),
    resolvePrintRecords,
    buildPreviewRows: (records) => buildInStatisticItemPrintRows(records),
    buildPreviewMeta: (rows) => ({
      reportTitle,
      reportDate: new Date().toLocaleDateString('zh-CN'),
      printedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length
    })
  })
  function handleSearch(params) {
    replaceSearchParams(buildInStatisticItemSearchParams(params))
    getData()
rsf-design/src/views/statistics/in-statistic/inStatisticPage.helpers.js
@@ -10,6 +10,7 @@
}
export const IN_STATISTIC_PAGE_TITLE = '入库统计'
export const IN_STATISTIC_REPORT_TITLE = '日入库汇总查询'
function normalizeText(value) {
  return String(value ?? '').trim()
@@ -52,7 +53,9 @@
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
    Object.entries(searchParams).filter(
      ([, value]) => value !== '' && value !== void 0 && value !== null
    )
  )
}
@@ -88,7 +91,9 @@
    dayTimeText: normalizeText(record.dayTime || record.day_time || ''),
    taskTypeText: normalizeText(record.taskTypeText || record['taskType$'] || taskTypeMeta.text),
    taskTypeTagType: normalizeText(record.taskTypeTagType || taskTypeMeta.type) || 'info',
    taskStatusText: normalizeText(record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text),
    taskStatusText: normalizeText(
      record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text
    ),
    taskStatusTagType: normalizeText(record.taskStatusTagType || taskStatusMeta.type) || 'info',
    locCode: normalizeText(record.locCode || record.loc_code || ''),
    barcode: normalizeText(record.barcode || ''),
@@ -105,3 +110,23 @@
    memo: normalizeText(record.memo || '')
  }
}
export function getInStatisticReportColumns() {
  return [
    { source: 'dayTimeText', label: '统计日期' },
    { source: 'matnrCode', label: '物料编码' },
    { source: 'maktx', label: '物料名称' },
    { source: 'anfme', label: '数量', align: 'right' },
    { source: 'batch', label: '批次' },
    { source: 'unit', label: '单位' },
    { source: 'taskTypeText', label: '任务类型' },
    { source: 'taskStatusText', label: '任务状态' }
  ]
}
export function buildInStatisticPrintRows(records = []) {
  if (!Array.isArray(records)) {
    return []
  }
  return records.map((record) => normalizeInStatisticRow(record))
}
rsf-design/src/views/statistics/in-statistic/index.vue
@@ -8,7 +8,24 @@
      @reset="handleReset"
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData" />
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
        <template #left>
          <ListExportPrint
            class="inline-flex"
            :preview-visible="previewVisible"
            @update:previewVisible="handlePreviewVisibleChange"
            :report-title="reportTitle"
            :query-params="reportQueryParams"
            :columns="reportColumns"
            :preview-rows="previewRows"
            :preview-meta="previewMeta"
            :total="pagination.total"
            :disabled="loading"
            @export="handleExport"
            @print="handlePrint"
          />
        </template>
      </ArtTableHeader>
      <ArtTable
        :loading="loading"
        :data="data"
@@ -24,30 +41,70 @@
<script setup>
  import { computed, ref } from 'vue'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { fetchInStatisticPage } from '@/api/in-statistic'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import {
    fetchExportInStatisticReport,
    fetchGetInStatisticMany,
    fetchInStatisticPage
  } from '@/api/in-statistic'
  import {
    buildInStatisticPrintRows,
    buildInStatisticPageQueryParams,
    buildInStatisticSearchParams,
    createInStatisticSearchState,
    getInStatisticPaginationKey,
    normalizeInStatisticRow
    getInStatisticReportColumns,
    normalizeInStatisticRow,
    IN_STATISTIC_REPORT_TITLE
  } from './inStatisticPage.helpers'
  import { createInStatisticTableColumns } from './inStatisticTable.columns'
  import InStatisticDetailDrawer from './modules/in-statistic-detail-drawer.vue'
  defineOptions({ name: 'InStatistic' })
  const userStore = useUserStore()
  const reportTitle = IN_STATISTIC_REPORT_TITLE
  const searchForm = ref(createInStatisticSearchState())
  const detailDrawerVisible = ref(false)
  const detailData = ref({})
  const reportColumns = getInStatisticReportColumns()
  const reportQueryParams = computed(() => buildInStatisticPageQueryParams(searchForm.value))
  const searchItems = computed(() => [
    { label: '关键字', key: 'condition', type: 'input', props: { clearable: true, placeholder: '请输入物料名称/编码/批次' } },
    { label: '统计日期', key: 'dayTime', type: 'date', props: { clearable: true, type: 'date', valueFormat: 'YYYY-MM-DD' } },
    { label: '物料名称', key: 'maktx', type: 'input', props: { clearable: true, placeholder: '请输入物料名称' } },
    { label: '物料编码', key: 'matnrCode', type: 'input', props: { clearable: true, placeholder: '请输入物料编码' } },
    { label: '批次', key: 'batch', type: 'input', props: { clearable: true, placeholder: '请输入批次' } }
    {
      label: '关键字',
      key: 'condition',
      type: 'input',
      props: { clearable: true, placeholder: '请输入物料名称/编码/批次' }
    },
    {
      label: '统计日期',
      key: 'dayTime',
      type: 'date',
      props: { clearable: true, type: 'date', valueFormat: 'YYYY-MM-DD' }
    },
    {
      label: '物料名称',
      key: 'maktx',
      type: 'input',
      props: { clearable: true, placeholder: '请输入物料名称' }
    },
    {
      label: '物料编码',
      key: 'matnrCode',
      type: 'input',
      props: { clearable: true, placeholder: '请输入物料编码' }
    },
    {
      label: '批次',
      key: 'batch',
      type: 'input',
      props: { clearable: true, placeholder: '请输入批次' }
    }
  ])
  function openDetail(row) {
@@ -75,10 +132,50 @@
      columnsFactory: () => createInStatisticTableColumns({ handleView: openDetail })
    },
    transform: {
      dataTransformer: (records) => (Array.isArray(records) ? records.map((item) => normalizeInStatisticRow(item)) : [])
      dataTransformer: (records) =>
        Array.isArray(records) ? records.map((item) => normalizeInStatisticRow(item)) : []
    }
  })
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchGetInStatisticMany(payload.ids)).records
    }
    return defaultResponseAdapter(
      await fetchInStatisticPage({
        ...reportQueryParams.value,
        current: 1,
        pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : 20
      })
    ).records
  }
  const {
    previewVisible,
    previewRows,
    previewMeta,
    handlePreviewVisibleChange,
    handleExport,
    handlePrint
  } = usePrintExportPage({
    downloadFileName: 'in-statistic-report.xlsx',
    requestExport: (payload) =>
      fetchExportInStatisticReport(payload, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      }),
    resolvePrintRecords,
    buildPreviewRows: (records) => buildInStatisticPrintRows(records),
    buildPreviewMeta: (rows) => ({
      reportTitle,
      reportDate: new Date().toLocaleDateString('zh-CN'),
      printedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length
    })
  })
  function handleSearch(params) {
    replaceSearchParams(buildInStatisticSearchParams(params))
    getData()
rsf-design/src/views/statistics/out-statistic-item/index.vue
@@ -9,7 +9,24 @@
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData" />
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
        <template #left>
          <ListExportPrint
            class="inline-flex"
            :preview-visible="previewVisible"
            @update:previewVisible="handlePreviewVisibleChange"
            :report-title="reportTitle"
            :query-params="reportQueryParams"
            :columns="reportColumns"
            :preview-rows="previewRows"
            :preview-meta="previewMeta"
            :total="pagination.total"
            :disabled="loading"
            @export="handleExport"
            @print="handlePrint"
          />
        </template>
      </ArtTableHeader>
      <ArtTable
        :loading="loading"
@@ -27,23 +44,38 @@
<script setup>
  import { computed, ref } from 'vue'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { fetchOutStatisticItemPage } from '@/api/out-statistic-item'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import {
    fetchExportOutStatisticItemReport,
    fetchGetOutStatisticItemMany,
    fetchOutStatisticItemPage
  } from '@/api/out-statistic-item'
  import {
    buildOutStatisticItemPrintRows,
    buildOutStatisticItemPageQueryParams,
    buildOutStatisticItemSearchParams,
    createOutStatisticItemSearchState,
    getOutStatisticItemPaginationKey,
    normalizeOutStatisticItemRow
    getOutStatisticItemReportColumns,
    normalizeOutStatisticItemRow,
    OUT_STATISTIC_ITEM_REPORT_TITLE
  } from './outStatisticItemPage.helpers'
  import { createOutStatisticItemTableColumns } from './outStatisticItemTable.columns'
  import OutStatisticItemDetailDrawer from './modules/out-statistic-item-detail-drawer.vue'
  defineOptions({ name: 'OutStatisticItem' })
  const userStore = useUserStore()
  const reportTitle = OUT_STATISTIC_ITEM_REPORT_TITLE
  const searchForm = ref(createOutStatisticItemSearchState())
  const detailDrawerVisible = ref(false)
  const detailData = ref({})
  const reportColumns = getOutStatisticItemReportColumns()
  const reportQueryParams = computed(() => buildOutStatisticItemPageQueryParams(searchForm.value))
  const searchItems = computed(() => [
    {
@@ -136,6 +168,45 @@
    }
  })
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchGetOutStatisticItemMany(payload.ids)).records
    }
    return defaultResponseAdapter(
      await fetchOutStatisticItemPage({
        ...reportQueryParams.value,
        current: 1,
        pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : 20
      })
    ).records
  }
  const {
    previewVisible,
    previewRows,
    previewMeta,
    handlePreviewVisibleChange,
    handleExport,
    handlePrint
  } = usePrintExportPage({
    downloadFileName: 'out-statistic-item-report.xlsx',
    requestExport: (payload) =>
      fetchExportOutStatisticItemReport(payload, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      }),
    resolvePrintRecords,
    buildPreviewRows: (records) => buildOutStatisticItemPrintRows(records),
    buildPreviewMeta: (rows) => ({
      reportTitle,
      reportDate: new Date().toLocaleDateString('zh-CN'),
      printedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length
    })
  })
  function handleSearch(params) {
    replaceSearchParams(buildOutStatisticItemSearchParams(params))
    getData()
rsf-design/src/views/statistics/out-statistic-item/outStatisticItemPage.helpers.js
@@ -4,6 +4,7 @@
} from '../out-statistic/outStatisticPage.helpers.js'
export const OUT_STATISTIC_ITEM_PAGE_TITLE = '出库统计明细'
export const OUT_STATISTIC_ITEM_REPORT_TITLE = '日出库明细查询'
function normalizeText(value) {
  return String(value ?? '').trim()
@@ -48,7 +49,9 @@
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
    Object.entries(searchParams).filter(
      ([, value]) => value !== '' && value !== void 0 && value !== null
    )
  )
}
@@ -70,7 +73,9 @@
    dayTimeText: normalizeText(record.dayTime || record.day_time || ''),
    taskTypeText: normalizeText(record.taskTypeText || record['taskType$'] || taskTypeMeta.text),
    taskTypeTagType: normalizeText(record.taskTypeTagType || taskTypeMeta.type) || 'info',
    taskStatusText: normalizeText(record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text),
    taskStatusText: normalizeText(
      record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text
    ),
    taskStatusTagType: normalizeText(record.taskStatusTagType || taskStatusMeta.type) || 'info',
    locCode: normalizeText(record.locCode || record.loc_code || ''),
    barcode: normalizeText(record.barcode || ''),
@@ -87,3 +92,29 @@
    memo: normalizeText(record.memo || '')
  }
}
export function getOutStatisticItemReportColumns() {
  return [
    { source: 'dayTimeText', label: '统计日期' },
    { source: 'locCode', label: '库位' },
    { source: 'matnrCode', label: '物料编码' },
    { source: 'maktx', label: '物料名称' },
    { source: 'anfme', label: '数量', align: 'right' },
    { source: 'batch', label: '批次' },
    { source: 'unit', label: '单位' },
    { source: 'barcode', label: '托盘码' },
    { source: 'taskTypeText', label: '任务类型' },
    { source: 'taskStatusText', label: '任务状态' },
    { source: 'createByText', label: '创建人' },
    { source: 'createTimeText', label: '创建时间' },
    { source: 'updateByText', label: '更新人' },
    { source: 'updateTimeText', label: '更新时间' }
  ]
}
export function buildOutStatisticItemPrintRows(records = []) {
  if (!Array.isArray(records)) {
    return []
  }
  return records.map((record) => normalizeOutStatisticItemRow(record))
}
rsf-design/src/views/statistics/out-statistic/index.vue
@@ -9,7 +9,24 @@
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData" />
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
        <template #left>
          <ListExportPrint
            class="inline-flex"
            :preview-visible="previewVisible"
            @update:previewVisible="handlePreviewVisibleChange"
            :report-title="reportTitle"
            :query-params="reportQueryParams"
            :columns="reportColumns"
            :preview-rows="previewRows"
            :preview-meta="previewMeta"
            :total="pagination.total"
            :disabled="loading"
            @export="handleExport"
            @print="handlePrint"
          />
        </template>
      </ArtTableHeader>
      <ArtTable
        :loading="loading"
@@ -27,25 +44,38 @@
<script setup>
  import { computed, ref } from 'vue'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { fetchOutStatisticPage } from '@/api/out-statistic'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import {
    fetchExportOutStatisticReport,
    fetchGetOutStatisticMany,
    fetchOutStatisticPage
  } from '@/api/out-statistic'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import {
    buildOutStatisticPrintRows,
    buildOutStatisticPageQueryParams,
    buildOutStatisticSearchParams,
    createOutStatisticSearchState,
    getOutStatisticPaginationKey,
    getOutStatisticReportColumns,
    normalizeOutStatisticRow,
    OUT_STATISTIC_PAGE_TITLE
    OUT_STATISTIC_REPORT_TITLE
  } from './outStatisticPage.helpers'
  import { createOutStatisticTableColumns } from './outStatisticTable.columns'
  import OutStatisticDetailDrawer from './modules/out-statistic-detail-drawer.vue'
  defineOptions({ name: 'OutStatistic' })
  const userStore = useUserStore()
  const reportTitle = OUT_STATISTIC_REPORT_TITLE
  const searchForm = ref(createOutStatisticSearchState())
  const detailDrawerVisible = ref(false)
  const detailData = ref({})
  const reportColumns = getOutStatisticReportColumns()
  const reportQueryParams = computed(() => buildOutStatisticPageQueryParams(searchForm.value))
  const searchItems = computed(() => [
    {
@@ -124,10 +154,50 @@
        })
    },
    transform: {
      dataTransformer: (records) => (Array.isArray(records) ? records.map((item) => normalizeOutStatisticRow(item)) : [])
      dataTransformer: (records) =>
        Array.isArray(records) ? records.map((item) => normalizeOutStatisticRow(item)) : []
    }
  })
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchGetOutStatisticMany(payload.ids)).records
    }
    return defaultResponseAdapter(
      await fetchOutStatisticPage({
        ...reportQueryParams.value,
        current: 1,
        pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : 20
      })
    ).records
  }
  const {
    previewVisible,
    previewRows,
    previewMeta,
    handlePreviewVisibleChange,
    handleExport,
    handlePrint
  } = usePrintExportPage({
    downloadFileName: 'out-statistic-report.xlsx',
    requestExport: (payload) =>
      fetchExportOutStatisticReport(payload, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      }),
    resolvePrintRecords,
    buildPreviewRows: (records) => buildOutStatisticPrintRows(records),
    buildPreviewMeta: (rows) => ({
      reportTitle,
      reportDate: new Date().toLocaleDateString('zh-CN'),
      printedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length
    })
  })
  function handleSearch(params) {
    replaceSearchParams(buildOutStatisticSearchParams(params))
    getData()
rsf-design/src/views/statistics/out-statistic/outStatisticPage.helpers.js
@@ -11,7 +11,7 @@
}
export const OUT_STATISTIC_PAGE_TITLE = '出库统计'
export const OUT_STATISTIC_REPORT_TITLE = '出库统计报表'
export const OUT_STATISTIC_REPORT_TITLE = '日出库汇总查询'
function normalizeText(value) {
  return String(value ?? '').trim()
@@ -54,7 +54,9 @@
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
    Object.entries(searchParams).filter(
      ([, value]) => value !== '' && value !== void 0 && value !== null
    )
  )
}
@@ -90,7 +92,9 @@
    dayTimeText: normalizeText(record.dayTime || record.day_time || ''),
    taskTypeText: normalizeText(record.taskTypeText || record['taskType$'] || taskTypeMeta.text),
    taskTypeTagType: normalizeText(record.taskTypeTagType || taskTypeMeta.type) || 'info',
    taskStatusText: normalizeText(record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text),
    taskStatusText: normalizeText(
      record.taskStatusText || record['taskStatus$'] || taskStatusMeta.text
    ),
    taskStatusTagType: normalizeText(record.taskStatusTagType || taskStatusMeta.type) || 'info',
    locCode: normalizeText(record.locCode || record.loc_code || ''),
    barcode: normalizeText(record.barcode || ''),
@@ -114,3 +118,16 @@
  }
  return records.map((record) => normalizeOutStatisticRow(record))
}
export function getOutStatisticReportColumns() {
  return [
    { source: 'dayTimeText', label: '统计日期' },
    { source: 'matnrCode', label: '物料编码' },
    { source: 'maktx', label: '物料名称' },
    { source: 'anfme', label: '数量', align: 'right' },
    { source: 'batch', label: '批次' },
    { source: 'unit', label: '单位' },
    { source: 'taskTypeText', label: '任务类型' },
    { source: 'taskStatusText', label: '任务状态' }
  ]
}
rsf-design/src/views/stock/warehouse-areas-item/index.vue
@@ -12,22 +12,22 @@
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="loadPageData">
        <template #left>
          <ElSpace wrap>
            <span v-auth="'list'" class="inline-flex">
              <ListExportPrint
                :preview-visible="previewVisible"
                @update:previewVisible="handlePreviewVisibleChange"
                :report-title="reportTitle"
                :selected-rows="selectedRows"
                :query-params="reportQueryParams"
                :columns="columns"
                :preview-rows="previewRows"
                :preview-meta="resolvedPreviewMeta"
                :total="pagination.total"
                :disabled="loading"
                @export="handleExport"
                @print="handlePrint"
              />
            </span>
            <ListExportPrint
              :preview-visible="previewVisible"
              @update:previewVisible="handlePreviewVisibleChange"
              :report-title="reportTitle"
              :selected-rows="selectedRows"
              :query-params="reportQueryParams"
              :columns="columns"
              :preview-rows="previewRows"
              :preview-meta="resolvedPreviewMeta"
              :total="pagination.total"
              :disabled="loading || exportTaskLoading"
              export-auth="manager:warehouseAreasItem:export"
              print-auth="list"
              @export="handleExportRequest"
              @print="handlePrint"
            />
          </ElSpace>
        </template>
      </ArtTableHeader>
@@ -57,7 +57,8 @@
</template>
<script setup>
  import { computed, onMounted, reactive, ref } from 'vue'
  import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { useUserStore } from '@/store/modules/user'
  import { useTableColumns } from '@/hooks/core/useTableColumns'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
@@ -65,9 +66,12 @@
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import {
    fetchCreateWarehouseAreasItemExportTask,
    fetchDownloadWarehouseAreasItemExportTask,
    fetchEnabledFields,
    fetchExportWarehouseAreasItemReport,
    fetchGetWarehouseAreasItemMany,
    fetchWarehouseAreasItemExportTask,
    fetchWarehouseAreasItemIsptPage,
    fetchWarehouseAreasItemPage
  } from '@/api/warehouse-areas-item'
@@ -90,6 +94,8 @@
  defineOptions({ name: 'WarehouseAreasItem' })
  const EXPORT_SYNC_MAX_ROWS = 5000
  const EXPORT_TASK_POLL_INTERVAL = 3000
  const userStore = useUserStore()
  const reportTitle = WAREHOUSE_AREAS_ITEM_REPORT_TITLE
  const loading = ref(false)
@@ -97,11 +103,13 @@
  const enabledFields = ref([])
  const selectedRows = ref([])
  const searchForm = ref(createWarehouseAreasItemSearchState())
  const exportTaskLoading = ref(false)
  const isptDrawerVisible = ref(false)
  const isptLoading = ref(false)
  const isptTableData = ref([])
  const activeRow = ref({})
  let exportTaskTimer = null
  const pagination = reactive({
    current: 1,
@@ -330,7 +338,9 @@
        }
      )
      tableData.value = Array.isArray(response?.records)
        ? response.records.map((record) => normalizeWarehouseAreasItemRow(record, enabledFields.value))
        ? response.records.map((record) =>
            normalizeWarehouseAreasItemRow(record, enabledFields.value)
          )
        : []
      updatePaginationState(pagination, response, pagination.current, pagination.size)
    } finally {
@@ -381,6 +391,129 @@
  function handleSelectionChange(rows) {
    selectedRows.value = Array.isArray(rows) ? rows : []
  }
  function clearExportTaskTimer() {
    if (exportTaskTimer) {
      clearTimeout(exportTaskTimer)
      exportTaskTimer = null
    }
  }
  function getExportRowCount(payload) {
    const selectedIds = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : []
    if (selectedIds.length > 0) {
      return selectedIds.length
    }
    return Number(pagination.total) || 0
  }
  function needsAsyncExport(payload) {
    return getExportRowCount(payload) > EXPORT_SYNC_MAX_ROWS
  }
  function scheduleExportTaskPoll(taskId) {
    clearExportTaskTimer()
    exportTaskTimer = setTimeout(() => {
      pollExportTask(taskId)
    }, EXPORT_TASK_POLL_INTERVAL)
  }
  function resolveDownloadFileName(task = {}, response) {
    const contentDisposition = response?.headers?.get('Content-Disposition') || ''
    const matchedPart = contentDisposition
      .split(';')
      .map((part) => part.trim())
      .find((part) => /^filename\*?=/i.test(part))
    if (matchedPart) {
      let fileName = matchedPart.replace(/^filename\*?=/i, '').trim()
      if (fileName.toLowerCase().startsWith("utf-8''")) {
        fileName = fileName.slice(7)
      }
      if (fileName.startsWith('"') && fileName.endsWith('"')) {
        fileName = fileName.slice(1, -1)
      }
      try {
        return decodeURIComponent(fileName)
      } catch {
        return fileName
      }
    }
    return task.fileName || 'warehouse-areas-item.xlsx'
  }
  async function downloadExportTaskFile(task) {
    const response = await fetchDownloadWarehouseAreasItemExportTask(task.id, {
      headers: {
        Authorization: userStore.accessToken || ''
      }
    })
    if (!response.ok) {
      throw new Error(`导出文件下载失败(${response.status})`)
    }
    const blob = await response.blob()
    const downloadUrl = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = downloadUrl
    link.download = resolveDownloadFileName(task, response)
    document.body.appendChild(link)
    link.click()
    link.remove()
    window.URL.revokeObjectURL(downloadUrl)
  }
  async function pollExportTask(taskId) {
    try {
      const task = await fetchWarehouseAreasItemExportTask(taskId)
      const status = Number(task?.status)
      if (status === 2) {
        clearExportTaskTimer()
        await downloadExportTaskFile(task)
        exportTaskLoading.value = false
        ElMessage.success(
          `导出任务已完成,已开始下载${task?.rowCount ? `(${task.rowCount}行)` : ''}`
        )
        return
      }
      if (status === 3) {
        clearExportTaskTimer()
        exportTaskLoading.value = false
        ElMessage.error(task?.errorMsg || '导出任务执行失败')
        return
      }
      scheduleExportTaskPoll(taskId)
    } catch (error) {
      clearExportTaskTimer()
      exportTaskLoading.value = false
      ElMessage.error(error?.message || '查询导出任务状态失败')
    }
  }
  async function handleExportRequest(payload) {
    if (!needsAsyncExport(payload)) {
      await handleExport(payload)
      return
    }
    const exportRowCount = getExportRowCount(payload)
    exportTaskLoading.value = true
    clearExportTaskTimer()
    try {
      const task = await fetchCreateWarehouseAreasItemExportTask(payload)
      ElMessage.success(
        `本次导出共 ${exportRowCount} 行,已超过 ${EXPORT_SYNC_MAX_ROWS} 行,系统已自动切换为后台导出任务${task?.taskCode ? `(${task.taskCode})` : ''}`
      )
      if (!task?.id) {
        throw new Error('导出任务创建成功,但未返回任务ID')
      }
      scheduleExportTaskPoll(task.id)
    } catch (error) {
      exportTaskLoading.value = false
      clearExportTaskTimer()
      ElMessage.error(error?.message || '创建导出任务失败')
    }
  }
  function handleSearch(params) {
@@ -447,7 +580,10 @@
        await fetchWarehouseAreasItemPage({
          ...reportQueryParams.value,
          current: 1,
          pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20
          pageSize:
            Number(pagination.total) > 0
              ? Number(pagination.total)
              : Number(payload?.pageSize) || 20
        })
      ).records
    },
@@ -471,7 +607,8 @@
    buildWarehouseAreasItemReportMeta({
      previewMeta: previewMeta.value,
      count: previewRows.value.length,
      orientation: previewMeta.value?.reportStyle?.orientation || WAREHOUSE_AREAS_ITEM_REPORT_STYLE.orientation
      orientation:
        previewMeta.value?.reportStyle?.orientation || WAREHOUSE_AREAS_ITEM_REPORT_STYLE.orientation
    })
  )
@@ -479,4 +616,8 @@
    await loadEnabledFieldDefinitions()
    await loadPageData()
  })
  onBeforeUnmount(() => {
    clearExportTaskTimer()
  })
</script>
rsf-design/src/views/system/export-task/exportTaskPage.helpers.js
New file
@@ -0,0 +1,109 @@
const STATUS_META = {
  0: { text: '待执行', type: 'info' },
  1: { text: '处理中', type: 'warning' },
  2: { text: '已完成', type: 'success' },
  3: { text: '失败', type: 'danger' }
}
const RESOURCE_LABEL_MAP = {
  loc: '库位',
  warehouseAreasItem: '收货库存'
}
function normalizeText(value) {
  return String(value ?? '').trim()
}
function normalizeNumber(value) {
  if (value === '' || value === null || value === undefined) {
    return undefined
  }
  const numericValue = Number(value)
  return Number.isFinite(numericValue) ? numericValue : undefined
}
function normalizeDateTime(value) {
  return normalizeText(value) || '--'
}
export function createExportTaskSearchState() {
  return {
    condition: '',
    resourceKey: '',
    status: '',
    timeStart: '',
    timeEnd: '',
    orderBy: 'createTime desc'
  }
}
export function getExportTaskPaginationKey() {
  return {
    current: 'current',
    size: 'pageSize'
  }
}
export function getExportTaskStatusOptions() {
  return Object.entries(STATUS_META).map(([value, meta]) => ({
    label: meta.text,
    value: Number(value)
  }))
}
export function getExportTaskResourceOptions() {
  return Object.entries(RESOURCE_LABEL_MAP).map(([value, label]) => ({
    label,
    value
  }))
}
export function buildExportTaskSearchParams(params = {}) {
  return {
    ...(normalizeText(params.condition) ? { condition: normalizeText(params.condition) } : {}),
    ...(normalizeText(params.resourceKey)
      ? { resourceKey: normalizeText(params.resourceKey) }
      : {}),
    ...(normalizeNumber(params.status) !== undefined
      ? { status: normalizeNumber(params.status) }
      : {}),
    ...(params.timeStart ? { timeStart: params.timeStart } : {}),
    ...(params.timeEnd ? { timeEnd: params.timeEnd } : {}),
    orderBy: 'createTime desc'
  }
}
export function buildExportTaskPageQueryParams(params = {}) {
  return {
    current: params.current || 1,
    pageSize: params.pageSize || params.size || 20,
    ...buildExportTaskSearchParams(params)
  }
}
export function resolveExportTaskResourceLabel(resourceKey) {
  return RESOURCE_LABEL_MAP[normalizeText(resourceKey)] || normalizeText(resourceKey) || '--'
}
export function normalizeExportTaskRow(record = {}) {
  const statusValue = Number(record.status)
  const statusMeta = STATUS_META[statusValue] || STATUS_META[0]
  return {
    ...record,
    id: record.id ?? '--',
    taskCode: normalizeText(record.taskCode) || '--',
    resourceKey: normalizeText(record.resourceKey),
    resourceKeyText: resolveExportTaskResourceLabel(record.resourceKey),
    reportTitle: normalizeText(record.reportTitle) || '--',
    fileName: normalizeText(record.fileName) || '--',
    rowCount: record.rowCount ?? '--',
    statusText: record['status$'] || statusMeta.text,
    statusType: statusMeta.type,
    errorMsg: normalizeText(record.errorMsg) || '--',
    createTimeText: normalizeDateTime(record['createTime$'] || record.createTime),
    updateTimeText: normalizeDateTime(record['updateTime$'] || record.updateTime),
    expireTimeText: normalizeDateTime(record['expireTime$'] || record.expireTime),
    canDownload: statusValue === 2
  }
}
rsf-design/src/views/system/export-task/exportTaskTable.columns.js
New file
@@ -0,0 +1,93 @@
import { h } from 'vue'
import { ElTag } from 'element-plus'
import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
export function createExportTaskTableColumns({ handleDownload } = {}) {
  return [
    { type: 'globalIndex', label: '序号', width: 72, align: 'center' },
    {
      prop: 'taskCode',
      label: '任务编号',
      minWidth: 200,
      showOverflowTooltip: true
    },
    {
      prop: 'resourceKeyText',
      label: '导出资源',
      minWidth: 120,
      showOverflowTooltip: true
    },
    {
      prop: 'reportTitle',
      label: '报表标题',
      minWidth: 180,
      showOverflowTooltip: true
    },
    {
      prop: 'rowCount',
      label: '数据量',
      width: 100,
      align: 'right'
    },
    {
      prop: 'fileName',
      label: '导出文件',
      minWidth: 240,
      showOverflowTooltip: true
    },
    {
      prop: 'statusText',
      label: '状态',
      width: 110,
      align: 'center',
      formatter: (row) =>
        h(
          ElTag,
          {
            type: row?.statusType || 'info',
            effect: 'light'
          },
          () => row?.statusText || '--'
        )
    },
    {
      prop: 'errorMsg',
      label: '错误信息',
      minWidth: 220,
      showOverflowTooltip: true
    },
    {
      prop: 'createTimeText',
      label: '创建时间',
      minWidth: 170,
      showOverflowTooltip: true
    },
    {
      prop: 'updateTimeText',
      label: '更新时间',
      minWidth: 170,
      showOverflowTooltip: true
    },
    {
      prop: 'expireTimeText',
      label: '过期时间',
      minWidth: 170,
      showOverflowTooltip: true
    },
    {
      prop: 'operation',
      label: '操作',
      width: 92,
      align: 'center',
      fixed: 'right',
      formatter: (row) =>
        row?.canDownload
          ? h(ArtButtonTable, {
              icon: 'ri:download-2-line',
              iconClass: 'bg-info/12 text-info',
              onClick: () => handleDownload?.(row)
            })
          : '--'
    }
  ]
}
rsf-design/src/views/system/export-task/index.vue
New file
@@ -0,0 +1,220 @@
<template>
  <div class="export-task-page art-full-height">
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
      :showExpand="false"
      @search="handleSearch"
      @reset="handleReset"
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData" />
      <ArtTable
        :loading="loading"
        :data="data"
        :columns="columns"
        :pagination="pagination"
        @pagination:size-change="handleSizeChange"
        @pagination:current-change="handleCurrentChange"
      />
    </ElCard>
  </div>
</template>
<script setup>
  import { computed, onBeforeUnmount, ref, watch } from 'vue'
  import { ElMessage } from 'element-plus'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { fetchDownloadExportTask, fetchExportTaskPage } from '@/api/system-manage'
  import {
    buildExportTaskPageQueryParams,
    buildExportTaskSearchParams,
    createExportTaskSearchState,
    getExportTaskPaginationKey,
    getExportTaskResourceOptions,
    getExportTaskStatusOptions,
    normalizeExportTaskRow
  } from './exportTaskPage.helpers'
  import { createExportTaskTableColumns } from './exportTaskTable.columns'
  defineOptions({ name: 'ExportTask' })
  const AUTO_REFRESH_INTERVAL = 5000
  const userStore = useUserStore()
  const searchForm = ref(createExportTaskSearchState())
  let autoRefreshTimer = null
  const searchItems = computed(() => [
    {
      label: '关键字',
      key: 'condition',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入任务编号/报表标题/文件名'
      }
    },
    {
      label: '导出资源',
      key: 'resourceKey',
      type: 'select',
      props: {
        clearable: true,
        filterable: true,
        options: getExportTaskResourceOptions()
      }
    },
    {
      label: '状态',
      key: 'status',
      type: 'select',
      props: {
        clearable: true,
        options: getExportTaskStatusOptions()
      }
    },
    {
      label: '开始日期',
      key: 'timeStart',
      type: 'date',
      props: {
        clearable: true,
        type: 'date',
        valueFormat: 'YYYY-MM-DD'
      }
    },
    {
      label: '结束日期',
      key: 'timeEnd',
      type: 'date',
      props: {
        clearable: true,
        type: 'date',
        valueFormat: 'YYYY-MM-DD'
      }
    }
  ])
  const {
    columns,
    columnChecks,
    data,
    loading,
    pagination,
    getData,
    replaceSearchParams,
    resetSearchParams,
    handleSizeChange,
    handleCurrentChange,
    refreshData
  } = useTable({
    core: {
      apiFn: fetchExportTaskPage,
      apiParams: buildExportTaskPageQueryParams(searchForm.value),
      paginationKey: getExportTaskPaginationKey(),
      columnsFactory: () =>
        createExportTaskTableColumns({
          handleDownload
        })
    },
    transform: {
      dataTransformer: (records) =>
        Array.isArray(records) ? records.map((item) => normalizeExportTaskRow(item)) : []
    }
  })
  function clearAutoRefreshTimer() {
    if (autoRefreshTimer) {
      clearTimeout(autoRefreshTimer)
      autoRefreshTimer = null
    }
  }
  function scheduleAutoRefresh() {
    clearAutoRefreshTimer()
    autoRefreshTimer = setTimeout(() => {
      refreshData()
    }, AUTO_REFRESH_INTERVAL)
  }
  function resolveDownloadFileName(row = {}, response) {
    const contentDisposition = response?.headers?.get('Content-Disposition') || ''
    const matchedPart = contentDisposition
      .split(';')
      .map((part) => part.trim())
      .find((part) => /^filename\*?=/i.test(part))
    if (matchedPart) {
      let fileName = matchedPart.replace(/^filename\*?=/i, '').trim()
      if (fileName.toLowerCase().startsWith("utf-8''")) {
        fileName = fileName.slice(7)
      }
      if (fileName.startsWith('"') && fileName.endsWith('"')) {
        fileName = fileName.slice(1, -1)
      }
      try {
        return decodeURIComponent(fileName)
      } catch {
        return fileName
      }
    }
    return row.fileName || `${row.taskCode || 'export-task'}.xlsx`
  }
  async function handleDownload(row) {
    try {
      const response = await fetchDownloadExportTask(row.id, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      })
      if (!response.ok) {
        throw new Error(`导出文件下载失败(${response.status})`)
      }
      const blob = await response.blob()
      const downloadUrl = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = downloadUrl
      link.download = resolveDownloadFileName(row, response)
      document.body.appendChild(link)
      link.click()
      link.remove()
      window.URL.revokeObjectURL(downloadUrl)
    } catch (error) {
      ElMessage.error(error?.message || '下载导出文件失败')
    }
  }
  function handleSearch(params) {
    replaceSearchParams(buildExportTaskSearchParams(params))
    getData()
  }
  function handleReset() {
    Object.assign(searchForm.value, createExportTaskSearchState())
    resetSearchParams()
  }
  watch(
    data,
    (rows) => {
      clearAutoRefreshTimer()
      if (Array.isArray(rows) && rows.some((item) => item?.status === 0 || item?.status === 1)) {
        scheduleAutoRefresh()
      }
    },
    {
      deep: true
    }
  )
  onBeforeUnmount(() => {
    clearAutoRefreshTimer()
  })
</script>
rsf-design/src/views/system/role/modules/role-permission-dialog.vue
@@ -63,10 +63,14 @@
              :data="scopeState[config.scopeType].treeData"
              node-key="id"
              show-checkbox
              check-strictly
              :default-expand-all="scopeState[config.scopeType].expandAll"
              :default-checked-keys="scopeState[config.scopeType].checkedKeys"
              :props="treeProps"
              @check="handleTreeCheck(config.scopeType)"
              @check-change="
                (data, checked) => handleTreeCheckChange(config.scopeType, data, checked)
              "
            >
              <template #default="{ data }">
                <div class="flex items-center gap-2">
@@ -143,7 +147,8 @@
      halfCheckedKeys: [],
      condition: '',
      expandAll: true,
      treeVersion: 0
      treeVersion: 0,
      syncingSelection: false
    }
  }
@@ -196,7 +201,13 @@
    } finally {
      state.loading = false
      nextTick(() => {
        treeRefs[scopeType]?.setCheckedKeys(scopeState[scopeType].checkedKeys)
        const tree = treeRefs[scopeType]
        if (!tree) return
        state.syncingSelection = true
        tree.setCheckedKeys(state.checkedKeys)
        nextTick(() => {
          state.syncingSelection = false
        })
      })
    }
  }
@@ -227,19 +238,63 @@
    scopeState[scopeType].halfCheckedKeys = normalizeScopeKeys(tree.getHalfCheckedKeys())
  }
  const handleTreeCheckChange = (scopeType, data, checked) => {
    const state = scopeState[scopeType]
    if (state.syncingSelection || !data?.id) {
      return
    }
    const tree = treeRefs[scopeType]
    if (!tree) {
      return
    }
    const nextCheckedKeys = new Set(state.checkedKeys.map((key) => String(key)))
    const currentKey = String(data.id)
    if (checked) {
      nextCheckedKeys.add(currentKey)
      getDescendantNodeKeys(data).forEach((key) => nextCheckedKeys.add(key))
      getParentNodeKeys(state.treeData, currentKey).forEach((key) => nextCheckedKeys.add(key))
    } else {
      nextCheckedKeys.delete(currentKey)
      getDescendantNodeKeys(data).forEach((key) => nextCheckedKeys.delete(key))
      let currentNodeKey = currentKey
      while (true) {
        const parentKey = getParentNodeKey(state.treeData, currentNodeKey)
        if (!parentKey) {
          break
        }
        const siblingKeys = getChildNodeKeys(state.treeData, parentKey)
        const hasCheckedSibling = siblingKeys.some((key) => nextCheckedKeys.has(String(key)))
        if (hasCheckedSibling) {
          break
        }
        nextCheckedKeys.delete(String(parentKey))
        currentNodeKey = String(parentKey)
      }
    }
    state.syncingSelection = true
    tree.setCheckedKeys(Array.from(nextCheckedKeys))
    nextTick(() => {
      handleTreeCheck(scopeType)
      state.syncingSelection = false
    })
  }
  const handleSelectAll = (scopeType) => {
    const tree = treeRefs[scopeType]
    if (!tree) return
    const allKeys = getAllNodeKeys(scopeState[scopeType].treeData)
    tree.setCheckedKeys(allKeys)
    handleTreeCheck(scopeType)
    applyCheckedKeys(scopeType, allKeys)
  }
  const handleClear = (scopeType) => {
    const tree = treeRefs[scopeType]
    if (!tree) return
    tree.setCheckedKeys([])
    handleTreeCheck(scopeType)
    applyCheckedKeys(scopeType, [])
  }
  const handleToggleExpand = (scopeType) => {
@@ -247,8 +302,7 @@
    state.expandAll = !state.expandAll
    state.treeVersion += 1
    nextTick(() => {
      treeRefs[scopeType]?.setCheckedKeys(state.checkedKeys)
      handleTreeCheck(scopeType)
      applyCheckedKeys(scopeType, state.checkedKeys)
    })
  }
@@ -314,6 +368,104 @@
    return keys
  }
  const getDescendantNodeKeys = (node) => {
    const keys = []
    const traverse = (children = []) => {
      children.forEach((child) => {
        if (hasNodeKey(child.id)) {
          keys.push(String(child.id))
        }
        if (child.children?.length) {
          traverse(child.children)
        }
      })
    }
    traverse(Array.isArray(node?.children) ? node.children : [])
    return keys
  }
  const getParentNodeKeys = (treeData, targetId) => {
    let parentKeys = []
    const traverse = (nodes, path = []) => {
      for (const node of nodes) {
        if (String(node.id) === String(targetId)) {
          parentKeys = path
          return true
        }
        if (node.children?.length) {
          if (traverse(node.children, [...path, String(node.id)])) {
            return true
          }
        }
      }
      return false
    }
    traverse(Array.isArray(treeData) ? treeData : [])
    return parentKeys
  }
  const getParentNodeKey = (treeData, targetId) => {
    let parentKey = ''
    const traverse = (nodes) => {
      for (const node of nodes) {
        if (node.children?.length) {
          for (const child of node.children) {
            if (String(child.id) === String(targetId)) {
              parentKey = String(node.id)
              return true
            }
          }
          if (traverse(node.children)) {
            return true
          }
        }
      }
      return false
    }
    traverse(Array.isArray(treeData) ? treeData : [])
    return parentKey
  }
  const getChildNodeKeys = (treeData, parentId) => {
    let childKeys = []
    const traverse = (nodes) => {
      for (const node of nodes) {
        if (String(node.id) === String(parentId)) {
          childKeys = Array.isArray(node.children)
            ? node.children.map((child) => String(child.id))
            : []
          return true
        }
        if (node.children?.length && traverse(node.children)) {
          return true
        }
      }
      return false
    }
    traverse(Array.isArray(treeData) ? treeData : [])
    return childKeys
  }
  const applyCheckedKeys = (scopeType, checkedKeys = []) => {
    const state = scopeState[scopeType]
    const tree = treeRefs[scopeType]
    state.checkedKeys = normalizeScopeKeys(checkedKeys)
    state.halfCheckedKeys = []
    if (!tree) {
      return
    }
    state.syncingSelection = true
    tree.setCheckedKeys(state.checkedKeys)
    nextTick(() => {
      handleTreeCheck(scopeType)
      state.syncingSelection = false
    })
  }
  watch(
    () => props.visible,
    async (isVisible) => {
rsf-server/src/main/java/com/vincent/rsf/server/common/service/AsyncListExportTaskService.java
New file
@@ -0,0 +1,34 @@
package com.vincent.rsf.server.common.service;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.system.entity.ExportTask;
import java.io.File;
import java.util.Map;
import java.util.function.Function;
public interface AsyncListExportTaskService {
    ExportTask createTask(
            String resourceKey,
            String defaultReportTitle,
            Map<String, Object> payload,
            Long tenantId,
            Long userId
    );
    ExportTask getTask(Long taskId, String resourceKey, Long tenantId, Long userId);
    ExportTask getTask(Long taskId, Long tenantId, Long userId);
    File getDownloadFile(Long taskId, String resourceKey, Long tenantId, Long userId);
    File getDownloadFile(Long taskId, Long tenantId, Long userId);
    <T, P extends BaseParam> void executeAsync(
            Long taskId,
            String resourceKey,
            Map<String, Object> payload,
            Function<Map<String, Object>, P> paramBuilder,
            ListExportHandler<T, P> exportHandler
    );
}
rsf-server/src/main/java/com/vincent/rsf/server/common/service/ListExportService.java
@@ -3,6 +3,7 @@
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletResponse;
@@ -35,6 +36,15 @@
            ListExportHandler<T, P> exportHandler,
            HttpServletResponse response
    ) throws Exception {
        ExportWorkbook exportWorkbook = prepareExportWorkbook(map, paramBuilder, exportHandler);
        ExcelUtil.build(exportWorkbook.workbook(), response);
    }
    public <T, P extends BaseParam> ExportWorkbook prepareExportWorkbook(
            Map<String, Object> map,
            Function<Map<String, Object>, P> paramBuilder,
            ListExportHandler<T, P> exportHandler
    ) {
        Map<String, Object> sanitizedMap = sanitizeExportMap(map);
        P baseParam = paramBuilder.apply(sanitizedMap);
        List<ExcelUtil.ExportColumn> columns = buildExportColumns(map);
@@ -50,7 +60,12 @@
                .toList();
        ExcelUtil.ExportMeta exportMeta = buildExportMeta(map, rows.size(), exportHandler.defaultReportTitle());
        ExcelUtil.build(ExcelUtil.create(rows, columns, exportMeta), response);
        return new ExportWorkbook(
                ExcelUtil.create(rows, columns, exportMeta),
                rows.size(),
                exportMeta,
                columns
        );
    }
    private Map<String, Object> sanitizeExportMap(Map<String, Object> map) {
@@ -171,4 +186,12 @@
        }
        return reportStyle;
    }
    public record ExportWorkbook(
            Workbook workbook,
            int rowCount,
            ExcelUtil.ExportMeta exportMeta,
            List<ExcelUtil.ExportColumn> columns
    ) {
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/common/service/impl/AsyncListExportTaskServiceImpl.java
New file
@@ -0,0 +1,225 @@
package com.vincent.rsf.server.common.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.service.AsyncListExportTaskService;
import com.vincent.rsf.server.common.service.ListExportHandler;
import com.vincent.rsf.server.common.service.ListExportService;
import com.vincent.rsf.server.system.entity.ExportTask;
import com.vincent.rsf.server.system.service.ExportTaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Function;
@Slf4j
@Service
@RequiredArgsConstructor
public class AsyncListExportTaskServiceImpl implements AsyncListExportTaskService {
    private final ExportTaskService exportTaskService;
    private final ListExportService listExportService;
    @Value("${logging.file.path:logs}")
    private String loggingFilePath;
    @Value("${rsf.export-task.expire-days:7}")
    private int exportTaskExpireDays;
    @Override
    public ExportTask createTask(
            String resourceKey,
            String defaultReportTitle,
            Map<String, Object> payload,
            Long tenantId,
            Long userId
    ) {
        ExportTask task = new ExportTask();
        task.setTaskCode(generateTaskCode());
        task.setResourceKey(resourceKey);
        task.setReportTitle(resolveReportTitle(payload, defaultReportTitle));
        task.setStatus(ExportTask.STATUS_PENDING);
        task.setPayloadJson(JSON.toJSONString(payload));
        task.setDeleted(0);
        task.setTenantId(tenantId);
        task.setCreateBy(userId);
        task.setUpdateBy(userId);
        task.setCreateTime(new Date());
        task.setUpdateTime(new Date());
        task.setExpireTime(resolveExpireTime());
        exportTaskService.save(task);
        return task;
    }
    @Override
    public ExportTask getTask(Long taskId, String resourceKey, Long tenantId, Long userId) {
        return exportTaskService.getOne(
                buildTaskQuery(taskId, tenantId, userId)
                        .eq(ExportTask::getResourceKey, resourceKey),
                false
        );
    }
    @Override
    public ExportTask getTask(Long taskId, Long tenantId, Long userId) {
        return exportTaskService.getOne(buildTaskQuery(taskId, tenantId, userId), false);
    }
    @Override
    public File getDownloadFile(Long taskId, String resourceKey, Long tenantId, Long userId) {
        ExportTask task = getTask(taskId, resourceKey, tenantId, userId);
        return resolveDownloadFile(task);
    }
    @Override
    public File getDownloadFile(Long taskId, Long tenantId, Long userId) {
        ExportTask task = getTask(taskId, tenantId, userId);
        return resolveDownloadFile(task);
    }
    private LambdaQueryWrapper<ExportTask> buildTaskQuery(Long taskId, Long tenantId, Long userId) {
        return new LambdaQueryWrapper<ExportTask>()
                .eq(ExportTask::getId, taskId)
                .eq(ExportTask::getDeleted, 0)
                .eq(ExportTask::getTenantId, tenantId)
                .eq(ExportTask::getCreateBy, userId);
    }
    private File resolveDownloadFile(ExportTask task) {
        if (task == null) {
            throw new CoolException("导出任务不存在");
        }
        if (!Objects.equals(task.getStatus(), ExportTask.STATUS_SUCCESS)) {
            throw new CoolException("导出任务尚未完成");
        }
        if (Cools.isEmpty(task.getFilePath())) {
            throw new CoolException("导出文件不存在");
        }
        File file = new File(task.getFilePath());
        if (!file.exists()) {
            throw new CoolException("导出文件不存在");
        }
        return file;
    }
    @Override
    @Async("taskExecutor")
    public <T, P extends BaseParam> void executeAsync(
            Long taskId,
            String resourceKey,
            Map<String, Object> payload,
            Function<Map<String, Object>, P> paramBuilder,
            ListExportHandler<T, P> exportHandler
    ) {
        ExportTask task = exportTaskService.getById(taskId);
        if (task == null) {
            return;
        }
        task.setStatus(ExportTask.STATUS_PROCESSING);
        task.setUpdateTime(new Date());
        exportTaskService.updateById(task);
        try {
            ListExportService.ExportWorkbook exportWorkbook =
                    listExportService.prepareExportWorkbook(payload, paramBuilder, exportHandler);
            File outputFile = createOutputFile(taskId, resourceKey, task.getReportTitle());
            writeWorkbook(exportWorkbook.workbook(), outputFile);
            task.setStatus(ExportTask.STATUS_SUCCESS);
            task.setRowCount(exportWorkbook.rowCount());
            task.setFileName(outputFile.getName());
        task.setFilePath(outputFile.getAbsolutePath());
        task.setErrorMsg(null);
        task.setUpdateTime(new Date());
        exportTaskService.updateById(task);
        } catch (Exception error) {
            log.error("异步导出失败, resourceKey={}, taskId={}", resourceKey, taskId, error);
            task.setStatus(ExportTask.STATUS_FAILED);
            task.setErrorMsg(truncateErrorMessage(error));
            task.setUpdateTime(new Date());
            exportTaskService.updateById(task);
        }
    }
    private String resolveReportTitle(Map<String, Object> payload, String defaultReportTitle) {
        Object metaObject = payload.get("meta");
        if (metaObject instanceof Map<?, ?> metaMap) {
            Object reportTitle = metaMap.get("reportTitle");
            if (reportTitle != null && !String.valueOf(reportTitle).trim().isEmpty()) {
                return String.valueOf(reportTitle).trim();
            }
        }
        return defaultReportTitle;
    }
    private String generateTaskCode() {
        return "EXP-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + "-"
                + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
    }
    private Date resolveExpireTime() {
        return Date.from(Instant.now().plus(Math.max(exportTaskExpireDays, 1), ChronoUnit.DAYS));
    }
    private File createOutputFile(Long taskId, String resourceKey, String reportTitle) {
        String safeTitle = sanitizeFileName(reportTitle);
        String safeResourceKey = sanitizePathSegment(resourceKey);
        String dayFolder = new SimpleDateFormat("yyyyMMdd").format(new Date());
        File dir = new File(loggingFilePath, "export-tasks/" + safeResourceKey + "/" + dayFolder);
        if (!dir.exists() && !dir.mkdirs()) {
            throw new CoolException("导出目录创建失败");
        }
        String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        return new File(dir, safeTitle + "-" + taskId + "-" + timestamp + ".xlsx");
    }
    private void writeWorkbook(Workbook workbook, File outputFile) throws IOException {
        try (Workbook targetWorkbook = workbook; FileOutputStream outputStream = new FileOutputStream(outputFile)) {
            targetWorkbook.write(outputStream);
            outputStream.flush();
        }
    }
    private String sanitizeFileName(String fileName) {
        String normalized = Objects.toString(fileName, "导出报表").trim();
        if (normalized.isEmpty()) {
            normalized = "导出报表";
        }
        return normalized.replaceAll("[\\\\/:*?\"<>|]", "_");
    }
    private String sanitizePathSegment(String segment) {
        String normalized = Objects.toString(segment, "default").trim();
        if (Cools.isEmpty(normalized)) {
            normalized = "default";
        }
        return normalized.replaceAll("[\\\\/:*?\"<>|]", "_");
    }
    private String truncateErrorMessage(Exception error) {
        String message = error == null ? "" : Objects.toString(error.getMessage(), error.getClass().getSimpleName());
        if (message.length() <= 500) {
            return message;
        }
        return message.substring(0, 500);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/common/utils/ExcelUtil.java
@@ -35,6 +35,10 @@
    private static final Pattern EXTEND_FIELD_SOURCE_PATTERN = Pattern.compile("^extendFields\\.\\[(.+)]$");
    private static final String SEQUENCE_SOURCE = "sequence";
    private static final String SEQUENCE_LABEL = "序号";
    private static final int EXCEL_WIDTH_UNIT = 256;
    private static final int MIN_COLUMN_WIDTH_CHARS = 8;
    private static final int MAX_COLUMN_WIDTH_CHARS = 60;
    private static final int COLUMN_WIDTH_PADDING_CHARS = 2;
    public static void build(Workbook workbook, HttpServletResponse response) {
        response.reset();
@@ -124,9 +128,7 @@
                }
            }
        }
        for (int i = 0; i <= fields.length; i++) {
            sheet.autoSizeColumn(i);
        }
        autoFitColumns(sheet, headerIdx, 0);
        return workbook;
    }
@@ -186,13 +188,74 @@
            }
        }
        for (int columnIndex = 0; columnIndex < effectiveColumns.size(); columnIndex++) {
            sheet.autoSizeColumn(columnIndex);
        }
        autoFitColumns(sheet, effectiveColumns.size(), header.getRowNum());
        return workbook;
    }
    private static void autoFitColumns(Sheet sheet, int columnCount, int startRowIndex) {
        if (sheet == null || columnCount <= 0) {
            return;
        }
        DataFormatter formatter = new DataFormatter(Locale.CHINA);
        for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
            int maxDisplayChars = MIN_COLUMN_WIDTH_CHARS;
            for (int rowIndex = Math.max(startRowIndex, 0); rowIndex <= sheet.getLastRowNum(); rowIndex++) {
                Row row = sheet.getRow(rowIndex);
                if (row == null) {
                    continue;
                }
                Cell cell = row.getCell(columnIndex);
                if (cell == null || isMergedCell(sheet, rowIndex, columnIndex)) {
                    continue;
                }
                maxDisplayChars = Math.max(maxDisplayChars, getDisplayChars(formatter.formatCellValue(cell)));
            }
            int widthChars = Math.min(MAX_COLUMN_WIDTH_CHARS, maxDisplayChars + COLUMN_WIDTH_PADDING_CHARS);
            sheet.setColumnWidth(columnIndex, widthChars * EXCEL_WIDTH_UNIT);
        }
    }
    private static boolean isMergedCell(Sheet sheet, int rowIndex, int columnIndex) {
        for (int index = 0; index < sheet.getNumMergedRegions(); index++) {
            CellRangeAddress region = sheet.getMergedRegion(index);
            if (region.isInRange(rowIndex, columnIndex)) {
                return true;
            }
        }
        return false;
    }
    private static int getDisplayChars(String value) {
        if (StringUtils.isBlank(value)) {
            return 0;
        }
        int maxLineChars = 0;
        for (String line : value.split("\\R", -1)) {
            maxLineChars = Math.max(maxLineChars, getLineDisplayChars(line));
        }
        return maxLineChars;
    }
    private static int getLineDisplayChars(String value) {
        int width = 0;
        for (int index = 0; index < value.length(); ) {
            int codePoint = value.codePointAt(index);
            width += isWideCodePoint(codePoint) ? 2 : 1;
            index += Character.charCount(codePoint);
        }
        return width;
    }
    private static boolean isWideCodePoint(int codePoint) {
        Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
        return script == Character.UnicodeScript.HAN
                || script == Character.UnicodeScript.HIRAGANA
                || script == Character.UnicodeScript.KATAKANA
                || script == Character.UnicodeScript.HANGUL;
    }
    private static List<ExportColumn> buildEffectiveColumns(List<ExportColumn> columns, ExportMeta exportMeta) {
        List<ExportColumn> effectiveColumns = new ArrayList<>(columns);
        if (exportMeta != null && exportMeta.isShowSequence() && !containsSequenceColumn(columns)) {
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocController.java
@@ -5,11 +5,15 @@
import com.vincent.rsf.framework.common.Cools;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.framework.exception.CoolException;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.common.annotation.OperationLog;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.KeyValVo;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.service.AsyncListExportTaskService;
import com.vincent.rsf.server.common.service.ListExportHandler;
import com.vincent.rsf.server.common.service.ListExportService;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.common.utils.FileServerUtil;
import com.vincent.rsf.server.common.utils.OptimisticLockUtils;
import com.vincent.rsf.server.manager.controller.params.LocMastInitParam;
import com.vincent.rsf.server.manager.controller.params.LocModifyParams;
@@ -19,16 +23,21 @@
import com.vincent.rsf.server.manager.service.LocService;
import com.vincent.rsf.server.manager.utils.buildPageRowsUtils;
import com.vincent.rsf.server.system.controller.BaseController;
import com.vincent.rsf.server.system.entity.ExportTask;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import java.io.File;
import java.util.*;
import java.util.stream.Collectors;
@@ -36,20 +45,52 @@
@Api(tags = "库位信息")
@RestController
public class LocController extends BaseController {
    private static final String EXPORT_RESOURCE_KEY = "loc";
    private static final String EXPORT_DEFAULT_REPORT_TITLE = "库位报表";
    @Autowired
    private LocService locService;
    @Autowired
    private ListExportService listExportService;
    @Autowired
    private AsyncListExportTaskService asyncListExportTaskService;
    private final ListExportHandler<Loc, BaseParam> locExportHandler = new ListExportHandler<>() {
        @Override
        public List<Loc> listByIds(List<Long> ids) {
            return locService.listByIds(ids);
        }
        @Override
        public List<Loc> listByFilter(Map<String, Object> sanitizedMap, BaseParam baseParam) {
            PageParam<Loc, BaseParam> pageParam = new PageParam<>(baseParam, Loc.class);
            return locService.list(pageParam.buildWrapper(true, getLocSortedFields()));
        }
        @Override
        public void fillExportFields(List<Loc> records) {
            buildPageRowsUtils.rowsMap(records);
        }
        @Override
        public Map<String, Object> toExportRow(Loc record, List<ExcelUtil.ExportColumn> columns) {
            return buildLocExportRow(record, columns);
        }
        @Override
        public String defaultReportTitle() {
            return EXPORT_DEFAULT_REPORT_TITLE;
        }
    };
    @PreAuthorize("hasAuthority('manager:loc:list')")
    @PostMapping("/loc/page")
    public R page(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<Loc, BaseParam> pageParam = new PageParam<>(baseParam, Loc.class);
        List<String> list = new ArrayList<>();
        list.add("row");
        list.add("col");
        list.add("lev");
        PageParam<Loc, BaseParam> page = locService.page(pageParam, pageParam.buildWrapper(true,list));
        PageParam<Loc, BaseParam> page = locService.page(pageParam, pageParam.buildWrapper(true, getLocSortedFields()));
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
@@ -214,17 +255,67 @@
        return R.ok().add(buildPageRowsUtils.rowsMap(vos));
    }
    @PreAuthorize("hasAuthority('manager:loc:list')")
    @PreAuthorize("hasAuthority('manager:loc:export')")
    @ApiOperation("库位导出")
    @PostMapping("/loc/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        List<Loc> locs = new ArrayList<>();
        if (Objects.isNull(map.get("ids"))) {
            locs = locService.list();
        } else {
            locs = locService.list(new LambdaQueryWrapper<Loc>().eq(Loc::getStatus, 1));
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                locExportHandler,
                response
        );
    }
    @PreAuthorize("hasAuthority('manager:loc:export')")
    @PostMapping("/loc/export/async")
    public R createAsyncExportTask(@RequestBody Map<String, Object> map) {
        ExportTask task = asyncListExportTaskService.createTask(
                EXPORT_RESOURCE_KEY,
                EXPORT_DEFAULT_REPORT_TITLE,
                map,
                getTenantId(),
                getLoginUserId()
        );
        asyncListExportTaskService.executeAsync(
                task.getId(),
                EXPORT_RESOURCE_KEY,
                new HashMap<>(map),
                exportMap -> buildParam(exportMap, BaseParam.class),
                locExportHandler
        );
        return R.ok("导出任务已创建").add(buildPageRowsUtils.rowsMap(task));
    }
    @PreAuthorize("hasAuthority('manager:loc:export')")
    @GetMapping("/loc/export/task/{taskId}")
    public R getExportTask(@PathVariable("taskId") Long taskId) {
        ExportTask task = asyncListExportTaskService.getTask(
                taskId,
                EXPORT_RESOURCE_KEY,
                getTenantId(),
                getLoginUserId()
        );
        if (task == null) {
            return R.error("导出任务不存在");
        }
        ExcelUtil.build(ExcelUtil.create(buildPageRowsUtils.rowsMap(locs), Loc.class), response);
        return R.ok().add(buildPageRowsUtils.rowsMap(task));
    }
    @PreAuthorize("hasAuthority('manager:loc:export')")
    @GetMapping("/loc/export/task/{taskId}/download")
    public void downloadExportTask(
            @PathVariable("taskId") Long taskId,
            HttpServletResponse response,
            HttpServletRequest request
    ) {
        File file = asyncListExportTaskService.getDownloadFile(
                taskId,
                EXPORT_RESOURCE_KEY,
                getTenantId(),
                getLoginUserId()
        );
        FileServerUtil.preview(file, true, file.getName(), null, null, response, request);
    }
    @PreAuthorize("hasAuthority('manager:loc:update')")
@@ -288,4 +379,49 @@
        return locService.initLocs(param, getLoginUserId());
    }
    private List<String> getLocSortedFields() {
        return Arrays.asList("row", "col", "lev");
    }
    private Map<String, Object> buildLocExportRow(Loc record, List<ExcelUtil.ExportColumn> columns) {
        BeanWrapper beanWrapper = new BeanWrapperImpl(record);
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("warehouseName", record.getWarehouseId$());
        row.put("areaName", record.getAreaId$());
        row.put("typeIdsText", record.getTypeIds$());
        row.put("useStatus", normalizeExportText(record.getUseStatus$()));
        row.put("flagLogicText", toYesNoText(record.getFlagLogic()));
        row.put("flagLabelMangeText", toYesNoText(record.getFlagLabelMange()));
        row.put("status", record.getStatus$());
        row.put("updateTimeText", record.getUpdateTime$());
        for (ExcelUtil.ExportColumn column : columns) {
            if (row.containsKey(column.getSource())) {
                continue;
            }
            if (beanWrapper.isReadableProperty(column.getSource())) {
                row.put(column.getSource(), beanWrapper.getPropertyValue(column.getSource()));
            }
        }
        return row;
    }
    private String toYesNoText(Object value) {
        if (value == null) {
            return "";
        }
        if (Objects.equals(value, 1) || Objects.equals(value, (short) 1) || Objects.equals(value, true)) {
            return "是";
        }
        if (Objects.equals(value, 0) || Objects.equals(value, (short) 0) || Objects.equals(value, false)) {
            return "否";
        }
        return String.valueOf(value);
    }
    private String normalizeExportText(String value) {
        return value == null ? "" : value.trim();
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemDeadController.java
@@ -10,6 +10,8 @@
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.KeyValVo;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.service.ListExportHandler;
import com.vincent.rsf.server.common.service.ListExportService;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.common.utils.FieldsUtils;
import com.vincent.rsf.server.manager.controller.params.LocToTaskParams;
@@ -21,6 +23,8 @@
import com.vincent.rsf.server.manager.utils.buildPageRowsUtils;
import com.vincent.rsf.server.system.controller.BaseController;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@@ -35,8 +39,39 @@
    private LocItemService locItemService;
    @Autowired
    private LocService locService;
    @Autowired
    private ListExportService listExportService;
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    private final ListExportHandler<LocItem, BaseParam> locDeadReportExportHandler = new ListExportHandler<>() {
        @Override
        public List<LocItem> listByIds(List<Long> ids) {
            return locItemService.listByIds(ids);
        }
        @Override
        public List<LocItem> listByFilter(Map<String, Object> sanitizedMap, BaseParam baseParam) {
            PageParam<LocItem, BaseParam> pageParam = new PageParam<>(baseParam, LocItem.class);
            return locItemService.list(pageParam.buildWrapper(true));
        }
        @Override
        public void fillExportFields(List<LocItem> records) {
            fillLocItemExtendFields(records);
            buildPageRowsUtils.rowsMap(records);
        }
        @Override
        public Map<String, Object> toExportRow(LocItem record, List<ExcelUtil.ExportColumn> columns) {
            return buildLocDeadReportExportRow(record, columns);
        }
        @Override
        public String defaultReportTitle() {
            return "库存停滞报表";
        }
    };
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/locDeadReport/page")
    public R page(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
@@ -44,19 +79,12 @@
        QueryWrapper<LocItem> wrapper = pageParam.buildWrapper(true);
        /**拼接扩展字段*/
        PageParam<LocItem, BaseParam> page = locItemService.page(pageParam, wrapper);
        List<LocItem> records = page.getRecords();
        for (LocItem record : records) {
            if (!Objects.isNull(record.getFieldsIndex())) {
                Map<String, String> fields = FieldsUtils.getFields(record.getFieldsIndex());
                record.setExtendFields(fields);
            }
        }
        page.setRecords(records);
        fillLocItemExtendFields(page.getRecords());
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/locDeadReport/useO/page")
    public R locUseOPage(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
@@ -72,14 +100,7 @@
        locItemQueryWrapper.apply(applySql);
        /**拼接扩展字段*/
        PageParam<LocItem, BaseParam> page = locItemService.page(pageParam, locItemQueryWrapper);
        List<LocItem> records = page.getRecords();
        for (LocItem record : records) {
            if (!Objects.isNull(record.getFieldsIndex())) {
                Map<String, String> fields = FieldsUtils.getFields(record.getFieldsIndex());
                record.setExtendFields(fields);
            }
        }
        page.setRecords(records);
        fillLocItemExtendFields(page.getRecords());
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
@@ -89,7 +110,7 @@
     * @param param
     * @return
     */
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @ApiOperation("生成库存出库任务")
    @PostMapping("/locDeadReport/generate/task")
    public R generateTask(@RequestBody LocToTaskParams param) {
@@ -125,7 +146,7 @@
     * @param map
     * @return
     */
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @ApiOperation("生成盘点出库任务")
    @PostMapping("/locDeadReport/check/task")
    public R genStatisticalTask(@RequestBody LocToTaskParams map) {
@@ -144,25 +165,25 @@
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/locDeadReport/list")
    public R list(@RequestBody Map<String, Object> map) {
        return R.ok().add(buildPageRowsUtils.rowsMap(locItemService.list()));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping({"/locDeadReport/many/{ids}", "/locDeadReport/many/{ids}"})
    public R many(@PathVariable Long[] ids) {
        return R.ok().add(buildPageRowsUtils.rowsMap(locItemService.listByIds(Arrays.asList(ids))));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @GetMapping("/locDeadReport/{id}")
    public R get(@PathVariable("id") Long id) {
        return R.ok().add(buildPageRowsUtils.rowsMap(locItemService.getById(id)));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:save')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:save')")
    @OperationLog("Create 库位明细")
    @PostMapping("/locDeadReport/save")
    public R save(@RequestBody LocItem locItem) {
@@ -176,7 +197,7 @@
        return R.ok("Save Success").add(buildPageRowsUtils.rowsMap(locItem));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:update')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:update')")
    @OperationLog("Update 库位明细")
    @PostMapping("/locDeadReport/update")
    public R update(@RequestBody LocItem locItem) {
@@ -188,7 +209,7 @@
        return R.ok("Update Success").add(buildPageRowsUtils.rowsMap(locItem));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:remove')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:remove')")
    @OperationLog("Delete 库位明细")
    @PostMapping("/locDeadReport/remove/{ids}")
    public R remove(@PathVariable Long[] ids) {
@@ -198,7 +219,7 @@
        return R.ok("Delete Success").add(buildPageRowsUtils.rowsMap(ids));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/locDeadReport/query")
    public R query(@RequestParam(required = false) String condition) {
        List<KeyValVo> vos = new ArrayList<>();
@@ -212,10 +233,47 @@
        return R.ok().add(buildPageRowsUtils.rowsMap(vos));
    }
    @PreAuthorize("hasAuthority('manager:statisticReport:list')")
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/locDeadReport/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        ExcelUtil.build(ExcelUtil.create(buildPageRowsUtils.rowsMap(locItemService.list()), LocItem.class), response);
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                locDeadReportExportHandler,
                response
        );
    }
    private void fillLocItemExtendFields(List<LocItem> records) {
        for (LocItem record : records) {
            if (!Objects.isNull(record.getFieldsIndex())) {
                Map<String, String> fields = FieldsUtils.getFields(record.getFieldsIndex());
                record.setExtendFields(fields);
            }
        }
    }
    private Map<String, Object> buildLocDeadReportExportRow(LocItem record, List<ExcelUtil.ExportColumn> columns) {
        BeanWrapper beanWrapper = new BeanWrapperImpl(record);
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("deadTime", record.getDeadTime());
        row.put("typeText", record.getType$());
        row.put("wkTypeText", record.getWkType$());
        row.put("statusText", record.getStatus$());
        row.put("createByText", record.getCreateBy$());
        row.put("createTimeText", record.getCreateTime$());
        row.put("updateByText", record.getUpdateBy$());
        row.put("updateTimeText", record.getUpdateTime$());
        for (ExcelUtil.ExportColumn column : columns) {
            if (row.containsKey(column.getSource())) {
                continue;
            }
            if (beanWrapper.isReadableProperty(column.getSource())) {
                row.put(column.getSource(), beanWrapper.getPropertyValue(column.getSource()));
            }
        }
        return row;
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/StockStatisticController.java
@@ -10,23 +10,31 @@
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.KeyValVo;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.service.ListExportHandler;
import com.vincent.rsf.server.common.service.ListExportService;
import com.vincent.rsf.server.manager.entity.StockStatistic;
import com.vincent.rsf.server.manager.enums.TaskStsType;
import com.vincent.rsf.server.manager.enums.TaskType;
import com.vincent.rsf.server.manager.service.StockStatisticService;
import com.vincent.rsf.server.manager.utils.buildPageRowsUtils;
import com.vincent.rsf.server.system.controller.BaseController;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletResponse;
import java.util.*;
import java.util.function.Function;
@RestController
public class StockStatisticController extends BaseController {
    @Autowired
    private StockStatisticService stockStatisticService;
    @Autowired
    private ListExportService listExportService;
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/stockStatistic/page")
@@ -42,11 +50,7 @@
    public R outStatisticPage(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<StockStatistic, BaseParam> pageParam = new PageParam<>(baseParam, StockStatistic.class);
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, day_time, task_type, task_status, " +
                "MAX(maktx) AS maktx, matnr_code, MAX(batch) AS batch, " +
                "SUM(anfme) AS anfme, MAX(unit) AS unit");
        wrapper.groupBy("day_time, task_type, task_status, matnr_code");
        QueryWrapper<StockStatistic> wrapper = buildStatisticSummaryWrapper(pageParam);
        PageParam<StockStatistic, BaseParam> page = stockStatisticService.page(pageParam, wrapper);
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
@@ -56,11 +60,7 @@
    public R inStatisticPage(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<StockStatistic, BaseParam> pageParam = new PageParam<>(baseParam, StockStatistic.class);
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, day_time, task_type, task_status, " +
                "MAX(maktx) AS maktx, matnr_code, MAX(batch) AS batch, " +
                "SUM(anfme) AS anfme, MAX(unit) AS unit");
        wrapper.groupBy("day_time, task_type, task_status, matnr_code");
        QueryWrapper<StockStatistic> wrapper = buildStatisticSummaryWrapper(pageParam);
        PageParam<StockStatistic, BaseParam> page = stockStatisticService.page(pageParam, wrapper);
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
@@ -70,11 +70,7 @@
    public R inStockItemPage(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<StockStatistic, BaseParam> pageParam = new PageParam<>(baseParam, StockStatistic.class);
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, loc_code, day_time, task_type, task_status, barcode, " +
                "MAX(maktx) AS maktx, matnr_code, MAX(batch) AS batch, SUM(anfme) AS anfme, " +
                "MAX(unit) AS unit, create_by, update_by, create_time, update_time");
        wrapper.groupBy("loc_code, day_time, task_type, task_status, barcode, matnr_code, create_by, update_by, create_time, update_time");
        QueryWrapper<StockStatistic> wrapper = buildStatisticItemWrapper(pageParam);
        PageParam<StockStatistic, BaseParam> page = stockStatisticService.page(pageParam, wrapper);
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
@@ -84,11 +80,7 @@
    public R outStockItemPage(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<StockStatistic, BaseParam> pageParam = new PageParam<>(baseParam, StockStatistic.class);
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, loc_code, day_time, task_type, task_status, barcode, " +
                "MAX(maktx) AS maktx, matnr_code, MAX(batch) AS batch, SUM(anfme) AS anfme, " +
                "MAX(unit) AS unit, create_by, update_by, create_time, update_time");
        wrapper.groupBy("loc_code, day_time, task_type, task_status, barcode, matnr_code, create_by, update_by, create_time, update_time");
        QueryWrapper<StockStatistic> wrapper = buildStatisticItemWrapper(pageParam);
        PageParam<StockStatistic, BaseParam> page = stockStatisticService.page(pageParam, wrapper);
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
@@ -98,14 +90,7 @@
    public R statisticNumPage(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<StockStatistic, BaseParam> pageParam = new PageParam<>(baseParam, StockStatistic.class);
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, day_time, COUNT(barcode) AS `count`, " +
                "SUM( anfme ) anfme," +
                "COUNT(IF (task_type = 1, 0, NULL)) in_anfme_count, " +
                "COUNT(IF ( task_type = 101, 0, NULL)) out_anfme_count, " +
                "SUM( CASE WHEN task_type = 1 THEN anfme ELSE 0 END ) in_anfme," +
                "SUM( CASE WHEN task_type = 101 THEN anfme ELSE 0 END ) out_anfme");
        wrapper.in("task_type", Arrays.asList(TaskType.TASK_TYPE_IN.type, TaskType.TASK_TYPE_OUT.type)).groupBy("day_time");
        QueryWrapper<StockStatistic> wrapper = buildStatisticNumWrapper(pageParam);
        PageParam<StockStatistic, BaseParam> page = stockStatisticService.page(pageParam, wrapper);
        return R.ok().add(buildPageRowsUtils.rowsMap(page));
    }
@@ -119,6 +104,36 @@
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping({"/stockStatistic/many/{ids}", "/stockStatistics/many/{ids}"})
    public R many(@PathVariable Long[] ids) {
        return R.ok().add(buildPageRowsUtils.rowsMap(stockStatisticService.listByIds(Arrays.asList(ids))));
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/outStatistic/many/{ids}")
    public R outStatisticMany(@PathVariable Long[] ids) {
        return R.ok().add(buildPageRowsUtils.rowsMap(stockStatisticService.listByIds(Arrays.asList(ids))));
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/inStatistic/many/{ids}")
    public R inStatisticMany(@PathVariable Long[] ids) {
        return R.ok().add(buildPageRowsUtils.rowsMap(stockStatisticService.listByIds(Arrays.asList(ids))));
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/inStatisticItem/many/{ids}")
    public R inStatisticItemMany(@PathVariable Long[] ids) {
        return R.ok().add(buildPageRowsUtils.rowsMap(stockStatisticService.listByIds(Arrays.asList(ids))));
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/outStatisticItem/many/{ids}")
    public R outStatisticItemMany(@PathVariable Long[] ids) {
        return R.ok().add(buildPageRowsUtils.rowsMap(stockStatisticService.listByIds(Arrays.asList(ids))));
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/statistic/num/many/{ids}")
    public R statisticNumMany(@PathVariable Long[] ids) {
        return R.ok().add(buildPageRowsUtils.rowsMap(stockStatisticService.listByIds(Arrays.asList(ids))));
    }
@@ -175,7 +190,171 @@
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/stockStatistic/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        ExcelUtil.build(ExcelUtil.create(buildPageRowsUtils.rowsMap(stockStatisticService.list()), StockStatistic.class), response);
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                createStockStatisticExportHandler("日库存统计报表", pageParam -> pageParam.buildWrapper(true)),
                response
        );
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/inStatistic/export")
    public void inStatisticExport(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                createStockStatisticExportHandler("日入库汇总查询", this::buildStatisticSummaryWrapper),
                response
        );
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/outStatistic/export")
    public void outStatisticExport(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                createStockStatisticExportHandler("日出库汇总查询", this::buildStatisticSummaryWrapper),
                response
        );
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/inStatisticItem/export")
    public void inStatisticItemExport(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                createStockStatisticExportHandler("日入库明细查询", this::buildStatisticItemWrapper),
                response
        );
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/outStatisticItem/export")
    public void outStatisticItemExport(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                createStockStatisticExportHandler("日出库明细查询", this::buildStatisticItemWrapper),
                response
        );
    }
    @PreAuthorize("hasAuthority('manager:stockStatistic:list')")
    @PostMapping("/statistic/num/export")
    public void statisticNumExport(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        listExportService.export(
                map,
                exportMap -> buildParam(exportMap, BaseParam.class),
                createStockStatisticExportHandler("日出入库汇总统计", this::buildStatisticNumWrapper),
                response
        );
    }
    private QueryWrapper<StockStatistic> buildStatisticSummaryWrapper(PageParam<StockStatistic, BaseParam> pageParam) {
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, day_time, task_type, task_status, " +
                "MAX(maktx) AS maktx, matnr_code, MAX(batch) AS batch, " +
                "SUM(anfme) AS anfme, MAX(unit) AS unit");
        wrapper.groupBy("day_time, task_type, task_status, matnr_code");
        return wrapper;
    }
    private QueryWrapper<StockStatistic> buildStatisticItemWrapper(PageParam<StockStatistic, BaseParam> pageParam) {
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, loc_code, day_time, task_type, task_status, barcode, " +
                "MAX(maktx) AS maktx, matnr_code, MAX(batch) AS batch, SUM(anfme) AS anfme, " +
                "MAX(unit) AS unit, create_by, update_by, create_time, update_time");
        wrapper.groupBy("loc_code, day_time, task_type, task_status, barcode, matnr_code, create_by, update_by, create_time, update_time");
        return wrapper;
    }
    private QueryWrapper<StockStatistic> buildStatisticNumWrapper(PageParam<StockStatistic, BaseParam> pageParam) {
        QueryWrapper<StockStatistic> wrapper = pageParam.buildWrapper(true);
        wrapper.select("MIN(id) AS id, day_time, COUNT(barcode) AS `count`, " +
                "SUM( anfme ) anfme," +
                "COUNT(IF (task_type = 1, 0, NULL)) in_anfme_count, " +
                "COUNT(IF ( task_type = 101, 0, NULL)) out_anfme_count, " +
                "SUM( CASE WHEN task_type = 1 THEN anfme ELSE 0 END ) in_anfme," +
                "SUM( CASE WHEN task_type = 101 THEN anfme ELSE 0 END ) out_anfme");
        wrapper.in("task_type", Arrays.asList(TaskType.TASK_TYPE_IN.type, TaskType.TASK_TYPE_OUT.type)).groupBy("day_time");
        return wrapper;
    }
    private ListExportHandler<StockStatistic, BaseParam> createStockStatisticExportHandler(
            String reportTitle,
            Function<PageParam<StockStatistic, BaseParam>, QueryWrapper<StockStatistic>> wrapperBuilder
    ) {
        return new ListExportHandler<>() {
            @Override
            public List<StockStatistic> listByIds(List<Long> ids) {
                return stockStatisticService.listByIds(ids);
            }
            @Override
            public List<StockStatistic> listByFilter(Map<String, Object> sanitizedMap, BaseParam baseParam) {
                PageParam<StockStatistic, BaseParam> pageParam = new PageParam<>(baseParam, StockStatistic.class);
                return stockStatisticService.list(wrapperBuilder.apply(pageParam));
            }
            @Override
            public void fillExportFields(List<StockStatistic> records) {
                buildPageRowsUtils.rowsMap(records);
            }
            @Override
            public Map<String, Object> toExportRow(StockStatistic record, List<ExcelUtil.ExportColumn> columns) {
                return buildStockStatisticExportRow(record, columns);
            }
            @Override
            public String defaultReportTitle() {
                return reportTitle;
            }
        };
    }
    private Map<String, Object> buildStockStatisticExportRow(StockStatistic record, List<ExcelUtil.ExportColumn> columns) {
        BeanWrapper beanWrapper = new BeanWrapperImpl(record);
        Map<String, Object> row = new LinkedHashMap<>();
        row.put("dayTimeText", record.getDayTime());
        row.put("taskTypeText", record.getTaskType$());
        row.put("taskStatusText", getTaskStatusText(record.getTaskStatus()));
        row.put("locCode", record.getLocCode());
        row.put("barcode", record.getBarcode());
        row.put("matnrCode", record.getMatnrCode());
        row.put("maktx", record.getMaktx());
        row.put("batch", record.getBatch());
        row.put("unit", record.getUnit());
        row.put("anfme", record.getAnfme());
        row.put("createByText", record.getCreateBy$());
        row.put("createTimeText", record.getCreateTime$());
        row.put("updateByText", record.getUpdateBy$());
        row.put("updateTimeText", record.getUpdateTime$());
        for (ExcelUtil.ExportColumn column : columns) {
            if (row.containsKey(column.getSource())) {
                continue;
            }
            if (beanWrapper.isReadableProperty(column.getSource())) {
                row.put(column.getSource(), beanWrapper.getPropertyValue(column.getSource()));
            }
        }
        return row;
    }
    private String getTaskStatusText(Integer taskStatus) {
        if (taskStatus == null) {
            return "";
        }
        for (TaskStsType type : TaskStsType.values()) {
            if (Objects.equals(type.id, taskStatus)) {
                return type.desc;
            }
        }
        return String.valueOf(taskStatus);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/WarehouseAreasItemController.java
@@ -10,14 +10,17 @@
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.KeyValVo;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.service.AsyncListExportTaskService;
import com.vincent.rsf.server.common.service.ListExportHandler;
import com.vincent.rsf.server.common.service.ListExportService;
import com.vincent.rsf.server.common.utils.ExcelUtil;
import com.vincent.rsf.server.common.utils.FileServerUtil;
import com.vincent.rsf.server.common.utils.FieldsUtils;
import com.vincent.rsf.server.manager.entity.WarehouseAreasItem;
import com.vincent.rsf.server.manager.service.WarehouseAreasItemService;
import com.vincent.rsf.server.manager.utils.buildPageRowsUtils;
import com.vincent.rsf.server.system.controller.BaseController;
import com.vincent.rsf.server.system.entity.ExportTask;
import io.swagger.annotations.Api;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
@@ -26,16 +29,24 @@
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.io.File;
import java.util.*;
@Api(tags = "库区库存明细")
@RestController
public class WarehouseAreasItemController extends BaseController {
    private static final String EXPORT_RESOURCE_KEY = "warehouseAreasItem";
    private static final String EXPORT_DEFAULT_REPORT_TITLE = "收货库存报表";
    @Autowired
    private WarehouseAreasItemService warehouseAreasItemService;
    @Autowired
    private ListExportService listExportService;
    @Autowired
    private AsyncListExportTaskService asyncListExportTaskService;
    private final ListExportHandler<WarehouseAreasItem, BaseParam> warehouseAreasItemExportHandler = new ListExportHandler<>() {
        @Override
@@ -63,7 +74,7 @@
        @Override
        public String defaultReportTitle() {
            return "收货库存报表";
            return EXPORT_DEFAULT_REPORT_TITLE;
        }
    };
@@ -175,7 +186,7 @@
        return R.ok().add(buildPageRowsUtils.rowsMap(vos));
    }
    @PreAuthorize("hasAuthority('manager:warehouseAreasItem:list')")
    @PreAuthorize("hasAuthority('manager:warehouseAreasItem:export')")
    @PostMapping("/warehouseAreasItem/export")
    public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception {
        listExportService.export(
@@ -186,6 +197,57 @@
        );
    }
    @PreAuthorize("hasAuthority('manager:warehouseAreasItem:export')")
    @PostMapping("/warehouseAreasItem/export/async")
    public R createAsyncExportTask(@RequestBody Map<String, Object> map) {
        ExportTask task = asyncListExportTaskService.createTask(
                EXPORT_RESOURCE_KEY,
                EXPORT_DEFAULT_REPORT_TITLE,
                map,
                getTenantId(),
                getLoginUserId()
        );
        asyncListExportTaskService.executeAsync(
                task.getId(),
                EXPORT_RESOURCE_KEY,
                new HashMap<>(map),
                exportMap -> buildParam(exportMap, BaseParam.class),
                warehouseAreasItemExportHandler
        );
        return R.ok("导出任务已创建").add(buildPageRowsUtils.rowsMap(task));
    }
    @PreAuthorize("hasAuthority('manager:warehouseAreasItem:export')")
    @GetMapping("/warehouseAreasItem/export/task/{taskId}")
    public R getExportTask(@PathVariable("taskId") Long taskId) {
        ExportTask task = asyncListExportTaskService.getTask(
                taskId,
                EXPORT_RESOURCE_KEY,
                getTenantId(),
                getLoginUserId()
        );
        if (task == null) {
            return R.error("导出任务不存在");
        }
        return R.ok().add(buildPageRowsUtils.rowsMap(task));
    }
    @PreAuthorize("hasAuthority('manager:warehouseAreasItem:export')")
    @GetMapping("/warehouseAreasItem/export/task/{taskId}/download")
    public void downloadExportTask(
            @PathVariable("taskId") Long taskId,
            HttpServletResponse response,
            HttpServletRequest request
    ) {
        File file = asyncListExportTaskService.getDownloadFile(
                taskId,
                EXPORT_RESOURCE_KEY,
                getTenantId(),
                getLoginUserId()
        );
        FileServerUtil.preview(file, true, file.getName(), null, null, response, request);
    }
    private void fillExtendFields(List<WarehouseAreasItem> records) {
        for (WarehouseAreasItem record : records) {
            if (!Objects.isNull(record.getFieldsIndex())) {
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ExportTaskController.java
New file
@@ -0,0 +1,72 @@
package com.vincent.rsf.server.system.controller;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.common.service.AsyncListExportTaskService;
import com.vincent.rsf.server.common.utils.FileServerUtil;
import com.vincent.rsf.server.manager.utils.buildPageRowsUtils;
import com.vincent.rsf.server.system.entity.ExportTask;
import com.vincent.rsf.server.system.service.ExportTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.util.Map;
@RestController
public class ExportTaskController extends BaseController {
    @Autowired
    private ExportTaskService exportTaskService;
    @Autowired
    private AsyncListExportTaskService asyncListExportTaskService;
    @PreAuthorize("hasAuthority('system:exportTask:list')")
    @PostMapping("/exportTask/page")
    public R page(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        if (baseParam.getOrderBy() == null || baseParam.getOrderBy().trim().isEmpty()) {
            baseParam.setOrderBy("create_time desc");
        }
        PageParam<ExportTask, BaseParam> pageParam = new PageParam<>(baseParam, ExportTask.class);
        return R.ok().add(
                buildPageRowsUtils.rowsMap(
                        exportTaskService.page(
                                pageParam,
                                pageParam.buildWrapper(true)
                                        .eq("deleted", 0)
                                        .eq("tenant_id", getTenantId())
                                        .eq("create_by", getLoginUserId())
                        )
                )
        );
    }
    @PreAuthorize("hasAuthority('system:exportTask:list')")
    @GetMapping("/exportTask/{taskId}")
    public R get(@PathVariable("taskId") Long taskId) {
        ExportTask task = asyncListExportTaskService.getTask(taskId, getTenantId(), getLoginUserId());
        if (task == null) {
            return R.error("导出任务不存在");
        }
        return R.ok().add(buildPageRowsUtils.rowsMap(task));
    }
    @PreAuthorize("hasAuthority('system:exportTask:list')")
    @GetMapping("/exportTask/{taskId}/download")
    public void download(
            @PathVariable("taskId") Long taskId,
            HttpServletResponse response,
            HttpServletRequest request
    ) {
        File file = asyncListExportTaskService.getDownloadFile(taskId, getTenantId(), getLoginUserId());
        FileServerUtil.preview(file, true, file.getName(), null, null, response, request);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/system/entity/ExportTask.java
New file
@@ -0,0 +1,127 @@
package com.vincent.rsf.server.system.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
@TableName("sys_export_task")
public class ExportTask implements Serializable {
    public static final int STATUS_PENDING = 0;
    public static final int STATUS_PROCESSING = 1;
    public static final int STATUS_SUCCESS = 2;
    public static final int STATUS_FAILED = 3;
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value = "ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty(value = "任务编码")
    private String taskCode;
    @ApiModelProperty(value = "资源标识")
    private String resourceKey;
    @ApiModelProperty(value = "报表标题")
    private String reportTitle;
    @ApiModelProperty(value = "状态")
    private Integer status;
    @ApiModelProperty(value = "导出总行数")
    private Integer rowCount;
    @ApiModelProperty(value = "文件名")
    private String fileName;
    @ApiModelProperty(value = "文件路径")
    private String filePath;
    @ApiModelProperty(value = "查询参数")
    private String payloadJson;
    @ApiModelProperty(value = "过期时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date expireTime;
    @ApiModelProperty(value = "错误信息")
    private String errorMsg;
    @ApiModelProperty(value = "删除标记")
    private Integer deleted;
    @ApiModelProperty(value = "租户")
    private Long tenantId;
    @ApiModelProperty(value = "创建人")
    private Long createBy;
    @ApiModelProperty(value = "创建时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
    @ApiModelProperty(value = "更新人")
    private Long updateBy;
    @ApiModelProperty(value = "更新时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date updateTime;
    @ApiModelProperty(value = "备注")
    private String memo;
    @TableField(exist = false)
    private String createBy$;
    @TableField(exist = false)
    private String updateBy$;
    public String getStatus$() {
        if (status == null) {
            return null;
        }
        return switch (status) {
            case STATUS_PENDING -> "待执行";
            case STATUS_PROCESSING -> "处理中";
            case STATUS_SUCCESS -> "已完成";
            case STATUS_FAILED -> "失败";
            default -> String.valueOf(status);
        };
    }
    public String getCreateTime$() {
        if (createTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(createTime);
    }
    public String getUpdateTime$() {
        if (updateTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(updateTime);
    }
    public String getExpireTime$() {
        if (expireTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(expireTime);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/system/mapper/ExportTaskMapper.java
New file
@@ -0,0 +1,9 @@
package com.vincent.rsf.server.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.system.entity.ExportTask;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ExportTaskMapper extends BaseMapper<ExportTask> {
}
rsf-server/src/main/java/com/vincent/rsf/server/system/service/ExportTaskService.java
New file
@@ -0,0 +1,7 @@
package com.vincent.rsf.server.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.system.entity.ExportTask;
public interface ExportTaskService extends IService<ExportTask> {
}
rsf-server/src/main/java/com/vincent/rsf/server/system/service/impl/ExportTaskServiceImpl.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.system.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.server.system.entity.ExportTask;
import com.vincent.rsf.server.system.mapper.ExportTaskMapper;
import com.vincent.rsf.server.system.service.ExportTaskService;
import org.springframework.stereotype.Service;
@Service
public class ExportTaskServiceImpl extends ServiceImpl<ExportTaskMapper, ExportTask> implements ExportTaskService {
}
rsf-server/src/main/resources/sql/20260417_export_task.sql
New file
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS `sys_export_task` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `task_code` varchar(64) NOT NULL COMMENT '任务编码',
  `resource_key` varchar(64) NOT NULL COMMENT '资源标识',
  `report_title` varchar(128) DEFAULT NULL COMMENT '报表标题',
  `status` int NOT NULL DEFAULT 0 COMMENT '状态 0待执行 1处理中 2成功 3失败',
  `row_count` int DEFAULT 0 COMMENT '导出行数',
  `file_name` varchar(255) DEFAULT NULL COMMENT '文件名',
  `file_path` varchar(1000) DEFAULT NULL COMMENT '文件路径',
  `payload_json` longtext COMMENT '导出参数',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
  `error_msg` varchar(500) DEFAULT NULL COMMENT '错误信息',
  `deleted` int NOT NULL DEFAULT 0 COMMENT '删除标记',
  `tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
  `create_by` bigint DEFAULT NULL COMMENT '创建人',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` bigint DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `memo` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`),
  KEY `idx_sys_export_task_resource_status` (`resource_key`, `status`),
  KEY `idx_sys_export_task_creator` (`create_by`, `tenant_id`),
  KEY `idx_sys_export_task_expire_time` (`expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导出任务表';
rsf-server/src/main/resources/sql/20260417_export_task_expire_time.sql
New file
@@ -0,0 +1,13 @@
-- 导出任务过期时间
-- 说明:
-- 1. 新增 expire_time 字段,用于定时清理导出任务和导出文件
-- 2. 既有任务默认按创建时间保留 7 天
ALTER TABLE `sys_export_task`
  ADD COLUMN `expire_time` datetime DEFAULT NULL COMMENT '过期时间' AFTER `payload_json`;
UPDATE `sys_export_task`
SET `expire_time` = DATE_ADD(COALESCE(`create_time`, NOW()), INTERVAL 7 DAY)
WHERE `expire_time` IS NULL;
CREATE INDEX `idx_sys_export_task_expire_time` ON `sys_export_task` (`expire_time`);
rsf-server/src/main/resources/sql/20260417_export_task_menu.sql
New file
@@ -0,0 +1,79 @@
-- 异步导出任务菜单
-- 说明:
-- 1. 在“系统设置”下补充“导出任务”页面菜单
-- 2. 权限标识为 system:exportTask:list
-- 3. 页面展示当前登录用户的异步导出任务,并支持下载已完成文件
SET @tenant_id := 1;
SET @system_menu_id := (
  SELECT id
  FROM sys_menu
  WHERE deleted = 0
    AND tenant_id = @tenant_id
    AND type = 0
    AND (
      route = '/system'
      OR component = 'system'
      OR name = 'menu.system'
    )
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_menu (
  name,
  parent_id,
  parent_name,
  path,
  path_name,
  route,
  component,
  brief,
  code,
  type,
  authority,
  icon,
  sort,
  meta,
  tenant_id,
  status,
  deleted,
  create_time,
  create_by,
  update_time,
  update_by,
  memo
)
SELECT
  'menu.exportTask',
  @system_menu_id,
  'menu.system',
  '/system/export-task',
  '/system/export-task',
  '/system/export-task',
  'exportTask',
  '异步导出任务',
  NULL,
  0,
  'system:exportTask:list',
  'History',
  90,
  NULL,
  @tenant_id,
  1,
  0,
  NOW(),
  1,
  NOW(),
  1,
  '当前用户异步导出任务列表'
FROM dual
WHERE @system_menu_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_menu
    WHERE deleted = 0
      AND tenant_id = @tenant_id
      AND authority = 'system:exportTask:list'
  );
rsf-server/src/main/resources/sql/20260417_loc_export_menu.sql
New file
@@ -0,0 +1,80 @@
-- 库位导出权限
-- 说明:
-- 1. 在“库位”菜单下补充独立导出按钮权限
-- 2. 权限标识为 manager:loc:export
-- 3. 该权限覆盖同步导出、异步导出任务创建、任务状态查询、导出文件下载
SET @tenant_id := 1;
SET @loc_menu_id := (
  SELECT id
  FROM sys_menu
  WHERE deleted = 0
    AND tenant_id = @tenant_id
    AND type = 0
    AND (
      route = '/basic-info/loc'
      OR route = '/manager/loc'
      OR component = 'loc'
      OR name = 'menu.loc'
    )
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_menu (
  name,
  parent_id,
  parent_name,
  path,
  path_name,
  route,
  component,
  brief,
  code,
  type,
  authority,
  icon,
  sort,
  meta,
  tenant_id,
  status,
  deleted,
  create_time,
  create_by,
  update_time,
  update_by,
  memo
)
SELECT
  'Export 库位',
  @loc_menu_id,
  'menu.loc',
  NULL,
  NULL,
  NULL,
  NULL,
  '库位导出权限',
  NULL,
  1,
  'manager:loc:export',
  NULL,
  20,
  NULL,
  @tenant_id,
  1,
  0,
  NOW(),
  1,
  NOW(),
  1,
  '库位同步导出、异步导出任务与下载'
FROM dual
WHERE @loc_menu_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_menu
    WHERE deleted = 0
      AND tenant_id = @tenant_id
      AND authority = 'manager:loc:export'
  );
rsf-server/src/main/resources/sql/20260417_warehouse_areas_item_export_menu.sql
New file
@@ -0,0 +1,79 @@
-- 收货库存导出权限
-- 说明:
-- 1. 在“收货库存”菜单下补充独立导出按钮权限
-- 2. 权限标识为 manager:warehouseAreasItem:export
-- 3. 该权限覆盖同步导出、异步导出任务创建、任务状态查询、导出文件下载
SET @tenant_id := 1;
SET @warehouse_areas_item_menu_id := (
  SELECT id
  FROM sys_menu
  WHERE deleted = 0
    AND tenant_id = @tenant_id
    AND type = 0
    AND (
      route = '/manager/warehouseAreasItem'
      OR component = 'warehouseAreasItem'
      OR name = 'menu.warehouseAreasItem'
    )
  ORDER BY id
  LIMIT 1
);
INSERT INTO sys_menu (
  name,
  parent_id,
  parent_name,
  path,
  path_name,
  route,
  component,
  brief,
  code,
  type,
  authority,
  icon,
  sort,
  meta,
  tenant_id,
  status,
  deleted,
  create_time,
  create_by,
  update_time,
  update_by,
  memo
)
SELECT
  'Export 收货库存',
  @warehouse_areas_item_menu_id,
  'menu.warehouseAreasItem',
  NULL,
  NULL,
  NULL,
  NULL,
  '收货库存导出权限',
  NULL,
  1,
  'manager:warehouseAreasItem:export',
  NULL,
  20,
  NULL,
  @tenant_id,
  1,
  0,
  NOW(),
  1,
  NOW(),
  1,
  '收货库存同步导出、异步导出任务与下载'
FROM dual
WHERE @warehouse_areas_item_menu_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1
    FROM sys_menu
    WHERE deleted = 0
      AND tenant_id = @tenant_id
      AND authority = 'manager:warehouseAreasItem:export'
  );