feat(warehouse): 新增批量导入功能并优化系统配置
- 添加批量导入页面支持Excel和图片上传解析 - 实现移动端批量导入功能界面和交互 - 集成图片压缩上传功能提升用户体验 - 增加批量任务状态管理和进度跟踪 - 完善批量数据确认和执行流程 - 配置环境变量支持Docker部署 - 提高文件上传大小限制增强实用性 - 优化系统配置以适应生产环境需求
This commit is contained in:
parent
d872e3e235
commit
e76c07fb5e
@ -0,0 +1,80 @@
|
||||
package com.ruoyi.web.controller.warehouse;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.warehouse.domain.WmsBatchActionUpdateRequest;
|
||||
import com.ruoyi.warehouse.domain.WmsBatchItem;
|
||||
import com.ruoyi.warehouse.domain.WmsBatchJob;
|
||||
import com.ruoyi.warehouse.service.IWmsBatchImportService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/warehouse/batch")
|
||||
public class WmsBatchImportController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
private IWmsBatchImportService batchImportService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(WmsBatchJob job)
|
||||
{
|
||||
startPage();
|
||||
return getDataTable(batchImportService.selectJobList(job));
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Log(title = "WMS批量导入", businessType = BusinessType.IMPORT)
|
||||
public AjaxResult upload(MultipartFile file) throws Exception
|
||||
{
|
||||
return success(batchImportService.uploadExcel(file, getUsername()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public AjaxResult getInfo(@PathVariable Long id)
|
||||
{
|
||||
return success(batchImportService.selectJobById(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{jobId}/items")
|
||||
public AjaxResult items(@PathVariable Long jobId)
|
||||
{
|
||||
return success(batchImportService.selectItemList(jobId));
|
||||
}
|
||||
|
||||
@PutMapping("/item")
|
||||
public AjaxResult updateItem(@RequestBody WmsBatchItem item)
|
||||
{
|
||||
return success(batchImportService.updateItem(item));
|
||||
}
|
||||
|
||||
@PutMapping("/{jobId}/items/action")
|
||||
public AjaxResult updateItemAction(@PathVariable Long jobId, @RequestBody WmsBatchActionUpdateRequest request)
|
||||
{
|
||||
return toAjax(batchImportService.updateItemAction(jobId, request.getActionType(), request.getItemIds()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/item/{id}")
|
||||
public AjaxResult deleteItem(@PathVariable Long id)
|
||||
{
|
||||
return toAjax(batchImportService.deleteItem(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{jobId}/confirm")
|
||||
@Log(title = "WMS批量确认执行", businessType = BusinessType.INSERT)
|
||||
public AjaxResult confirm(@PathVariable Long jobId)
|
||||
{
|
||||
return success(batchImportService.confirmJob(jobId, getUsername()));
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,8 @@ ruoyi:
|
||||
version: 3.9.2
|
||||
# 版权年份
|
||||
copyrightYear: 2026
|
||||
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
|
||||
profile: D:/ruoyi/uploadPath
|
||||
# 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux/Docker配置 /app/uploadPath)
|
||||
profile: ${RUOYI_PROFILE:D:/ruoyi/uploadPath}
|
||||
# 获取ip地址开关
|
||||
addressEnabled: false
|
||||
# 验证码类型 math 数字计算 char 字符验证
|
||||
@ -56,9 +56,9 @@ spring:
|
||||
servlet:
|
||||
multipart:
|
||||
# 单个文件大小
|
||||
max-file-size: 10MB
|
||||
max-file-size: ${MAX_FILE_SIZE:50MB}
|
||||
# 设置总上传的文件大小
|
||||
max-request-size: 20MB
|
||||
max-request-size: ${MAX_REQUEST_SIZE:60MB}
|
||||
# 服务模块
|
||||
devtools:
|
||||
restart:
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package com.ruoyi.warehouse.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WmsBatchActionUpdateRequest
|
||||
{
|
||||
private String actionType;
|
||||
private List<Long> itemIds;
|
||||
|
||||
public String getActionType()
|
||||
{
|
||||
return actionType;
|
||||
}
|
||||
|
||||
public void setActionType(String actionType)
|
||||
{
|
||||
this.actionType = actionType;
|
||||
}
|
||||
|
||||
public List<Long> getItemIds()
|
||||
{
|
||||
return itemIds;
|
||||
}
|
||||
|
||||
public void setItemIds(List<Long> itemIds)
|
||||
{
|
||||
this.itemIds = itemIds;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.ruoyi.warehouse.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
public class WmsBatchItem extends BaseEntity
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private Long jobId;
|
||||
private Integer rowNo;
|
||||
private String actionType;
|
||||
private String rawText;
|
||||
private String parsedJson;
|
||||
private Integer status;
|
||||
private String errorMsg;
|
||||
private String warningMsg;
|
||||
private Long refId;
|
||||
private String areaName;
|
||||
private String productName;
|
||||
private String brand;
|
||||
private String category;
|
||||
private String spec;
|
||||
private String unit;
|
||||
private BigDecimal quantity;
|
||||
private String operatorName;
|
||||
private Integer typeCode;
|
||||
private String sourceNo;
|
||||
private String remark;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public Long getJobId() { return jobId; }
|
||||
public void setJobId(Long jobId) { this.jobId = jobId; }
|
||||
public Integer getRowNo() { return rowNo; }
|
||||
public void setRowNo(Integer rowNo) { this.rowNo = rowNo; }
|
||||
public String getActionType() { return actionType; }
|
||||
public void setActionType(String actionType) { this.actionType = actionType; }
|
||||
public String getRawText() { return rawText; }
|
||||
public void setRawText(String rawText) { this.rawText = rawText; }
|
||||
public String getParsedJson() { return parsedJson; }
|
||||
public void setParsedJson(String parsedJson) { this.parsedJson = parsedJson; }
|
||||
public Integer getStatus() { return status; }
|
||||
public void setStatus(Integer status) { this.status = status; }
|
||||
public String getErrorMsg() { return errorMsg; }
|
||||
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
|
||||
public String getWarningMsg() { return warningMsg; }
|
||||
public void setWarningMsg(String warningMsg) { this.warningMsg = warningMsg; }
|
||||
public Long getRefId() { return refId; }
|
||||
public void setRefId(Long refId) { this.refId = refId; }
|
||||
public String getAreaName() { return areaName; }
|
||||
public void setAreaName(String areaName) { this.areaName = areaName; }
|
||||
public String getProductName() { return productName; }
|
||||
public void setProductName(String productName) { this.productName = productName; }
|
||||
public String getBrand() { return brand; }
|
||||
public void setBrand(String brand) { this.brand = brand; }
|
||||
public String getCategory() { return category; }
|
||||
public void setCategory(String category) { this.category = category; }
|
||||
public String getSpec() { return spec; }
|
||||
public void setSpec(String spec) { this.spec = spec; }
|
||||
public String getUnit() { return unit; }
|
||||
public void setUnit(String unit) { this.unit = unit; }
|
||||
public BigDecimal getQuantity() { return quantity; }
|
||||
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
|
||||
public String getOperatorName() { return operatorName; }
|
||||
public void setOperatorName(String operatorName) { this.operatorName = operatorName; }
|
||||
public Integer getTypeCode() { return typeCode; }
|
||||
public void setTypeCode(Integer typeCode) { this.typeCode = typeCode; }
|
||||
public String getSourceNo() { return sourceNo; }
|
||||
public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; }
|
||||
public String getRemark() { return remark; }
|
||||
public void setRemark(String remark) { this.remark = remark; }
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
package com.ruoyi.warehouse.domain;
|
||||
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
public class WmsBatchJob extends BaseEntity
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String jobNo;
|
||||
private String sourceType;
|
||||
private String bizType;
|
||||
private String fileName;
|
||||
private String filePath;
|
||||
private Integer status;
|
||||
private Integer progress;
|
||||
private Integer totalCount;
|
||||
private Integer successCount;
|
||||
private Integer failCount;
|
||||
private String errorMsg;
|
||||
|
||||
public Long getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getJobNo()
|
||||
{
|
||||
return jobNo;
|
||||
}
|
||||
|
||||
public void setJobNo(String jobNo)
|
||||
{
|
||||
this.jobNo = jobNo;
|
||||
}
|
||||
|
||||
public String getSourceType()
|
||||
{
|
||||
return sourceType;
|
||||
}
|
||||
|
||||
public void setSourceType(String sourceType)
|
||||
{
|
||||
this.sourceType = sourceType;
|
||||
}
|
||||
|
||||
public String getBizType()
|
||||
{
|
||||
return bizType;
|
||||
}
|
||||
|
||||
public void setBizType(String bizType)
|
||||
{
|
||||
this.bizType = bizType;
|
||||
}
|
||||
|
||||
public String getFileName()
|
||||
{
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName)
|
||||
{
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getFilePath()
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public void setFilePath(String filePath)
|
||||
{
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
public Integer getStatus()
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Integer getProgress()
|
||||
{
|
||||
return progress;
|
||||
}
|
||||
|
||||
public void setProgress(Integer progress)
|
||||
{
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
public Integer getTotalCount()
|
||||
{
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
public void setTotalCount(Integer totalCount)
|
||||
{
|
||||
this.totalCount = totalCount;
|
||||
}
|
||||
|
||||
public Integer getSuccessCount()
|
||||
{
|
||||
return successCount;
|
||||
}
|
||||
|
||||
public void setSuccessCount(Integer successCount)
|
||||
{
|
||||
this.successCount = successCount;
|
||||
}
|
||||
|
||||
public Integer getFailCount()
|
||||
{
|
||||
return failCount;
|
||||
}
|
||||
|
||||
public void setFailCount(Integer failCount)
|
||||
{
|
||||
this.failCount = failCount;
|
||||
}
|
||||
|
||||
public String getErrorMsg()
|
||||
{
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
public void setErrorMsg(String errorMsg)
|
||||
{
|
||||
this.errorMsg = errorMsg;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.ruoyi.warehouse.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.warehouse.domain.WmsBatchItem;
|
||||
|
||||
public interface WmsBatchItemMapper
|
||||
{
|
||||
public List<WmsBatchItem> selectWmsBatchItemList(WmsBatchItem item);
|
||||
public WmsBatchItem selectWmsBatchItemById(Long id);
|
||||
public int insertWmsBatchItem(WmsBatchItem item);
|
||||
public int updateWmsBatchItem(WmsBatchItem item);
|
||||
public int deleteWmsBatchItemById(Long id);
|
||||
public int deleteWmsBatchItemByJobId(Long jobId);
|
||||
public int countByJobIdAndStatus(WmsBatchItem item);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.ruoyi.warehouse.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.warehouse.domain.WmsBatchJob;
|
||||
|
||||
public interface WmsBatchJobMapper
|
||||
{
|
||||
public List<WmsBatchJob> selectWmsBatchJobList(WmsBatchJob job);
|
||||
public WmsBatchJob selectWmsBatchJobById(Long id);
|
||||
public int insertWmsBatchJob(WmsBatchJob job);
|
||||
public int updateWmsBatchJob(WmsBatchJob job);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.ruoyi.warehouse.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.ruoyi.warehouse.domain.WmsBatchItem;
|
||||
import com.ruoyi.warehouse.domain.WmsBatchJob;
|
||||
|
||||
public interface IWmsBatchImportService
|
||||
{
|
||||
public WmsBatchJob uploadExcel(MultipartFile file, String username) throws Exception;
|
||||
public List<WmsBatchJob> selectJobList(WmsBatchJob job);
|
||||
public WmsBatchJob selectJobById(Long id);
|
||||
public List<WmsBatchItem> selectItemList(Long jobId);
|
||||
public WmsBatchItem updateItem(WmsBatchItem item);
|
||||
public int updateItemAction(Long jobId, String actionType, List<Long> itemIds);
|
||||
public int deleteItem(Long id);
|
||||
public WmsBatchJob confirmJob(Long jobId, String username);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.warehouse.mapper.WmsBatchItemMapper">
|
||||
<resultMap type="WmsBatchItem" id="WmsBatchItemResult">
|
||||
<id property="id" column="id"/>
|
||||
<result property="jobId" column="job_id"/>
|
||||
<result property="rowNo" column="row_no"/>
|
||||
<result property="actionType" column="action_type"/>
|
||||
<result property="rawText" column="raw_text"/>
|
||||
<result property="parsedJson" column="parsed_json"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="errorMsg" column="error_msg"/>
|
||||
<result property="warningMsg" column="warning_msg"/>
|
||||
<result property="refId" column="ref_id"/>
|
||||
<result property="areaName" column="area_name"/>
|
||||
<result property="productName" column="product_name"/>
|
||||
<result property="brand" column="brand"/>
|
||||
<result property="category" column="category"/>
|
||||
<result property="spec" column="spec"/>
|
||||
<result property="unit" column="unit"/>
|
||||
<result property="quantity" column="quantity"/>
|
||||
<result property="operatorName" column="operator_name"/>
|
||||
<result property="typeCode" column="type_code"/>
|
||||
<result property="sourceNo" column="source_no"/>
|
||||
<result property="remark" column="remark"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="updateTime" column="update_time"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectWmsBatchItemVo">
|
||||
select id, job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg, ref_id,
|
||||
area_name, product_name, brand, category, spec, unit, quantity, operator_name, type_code,
|
||||
source_no, remark, create_time, update_time
|
||||
from wms_batch_item
|
||||
</sql>
|
||||
|
||||
<select id="selectWmsBatchItemList" parameterType="WmsBatchItem" resultMap="WmsBatchItemResult">
|
||||
<include refid="selectWmsBatchItemVo"/>
|
||||
<where>
|
||||
<if test="jobId != null">and job_id = #{jobId}</if>
|
||||
<if test="status != null">and status = #{status}</if>
|
||||
<if test="actionType != null and actionType != ''">and action_type = #{actionType}</if>
|
||||
</where>
|
||||
order by row_no asc, id asc
|
||||
</select>
|
||||
|
||||
<select id="selectWmsBatchItemById" parameterType="Long" resultMap="WmsBatchItemResult">
|
||||
<include refid="selectWmsBatchItemVo"/> where id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="countByJobIdAndStatus" parameterType="WmsBatchItem" resultType="int">
|
||||
select count(1) from wms_batch_item where job_id = #{jobId} and status = #{status}
|
||||
</select>
|
||||
|
||||
<insert id="insertWmsBatchItem" parameterType="WmsBatchItem" useGeneratedKeys="true" keyProperty="id">
|
||||
insert into wms_batch_item(job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg,
|
||||
ref_id, area_name, product_name, brand, category, spec, unit, quantity,
|
||||
operator_name, type_code, source_no, remark, create_time)
|
||||
values(#{jobId}, #{rowNo}, #{actionType}, #{rawText}, #{parsedJson}, #{status}, #{errorMsg}, #{warningMsg},
|
||||
#{refId}, #{areaName}, #{productName}, #{brand}, #{category}, #{spec}, #{unit}, #{quantity},
|
||||
#{operatorName}, #{typeCode}, #{sourceNo}, #{remark}, sysdate())
|
||||
</insert>
|
||||
|
||||
<update id="updateWmsBatchItem" parameterType="WmsBatchItem">
|
||||
update wms_batch_item
|
||||
<set>
|
||||
row_no = #{rowNo},
|
||||
action_type = #{actionType},
|
||||
raw_text = #{rawText},
|
||||
parsed_json = #{parsedJson},
|
||||
status = #{status},
|
||||
error_msg = #{errorMsg},
|
||||
warning_msg = #{warningMsg},
|
||||
ref_id = #{refId},
|
||||
area_name = #{areaName},
|
||||
product_name = #{productName},
|
||||
brand = #{brand},
|
||||
category = #{category},
|
||||
spec = #{spec},
|
||||
unit = #{unit},
|
||||
quantity = #{quantity},
|
||||
operator_name = #{operatorName},
|
||||
type_code = #{typeCode},
|
||||
source_no = #{sourceNo},
|
||||
remark = #{remark},
|
||||
update_time = sysdate()
|
||||
</set>
|
||||
where id = #{id}
|
||||
</update>
|
||||
|
||||
<delete id="deleteWmsBatchItemById" parameterType="Long">
|
||||
delete from wms_batch_item where id = #{id}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteWmsBatchItemByJobId" parameterType="Long">
|
||||
delete from wms_batch_item where job_id = #{value}
|
||||
</delete>
|
||||
</mapper>
|
||||
@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.warehouse.mapper.WmsBatchJobMapper">
|
||||
<resultMap type="WmsBatchJob" id="WmsBatchJobResult">
|
||||
<id property="id" column="id"/>
|
||||
<result property="jobNo" column="job_no"/>
|
||||
<result property="sourceType" column="source_type"/>
|
||||
<result property="bizType" column="biz_type"/>
|
||||
<result property="fileName" column="file_name"/>
|
||||
<result property="filePath" column="file_path"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="progress" column="progress"/>
|
||||
<result property="totalCount" column="total_count"/>
|
||||
<result property="successCount" column="success_count"/>
|
||||
<result property="failCount" column="fail_count"/>
|
||||
<result property="errorMsg" column="error_msg"/>
|
||||
<result property="createBy" column="create_by"/>
|
||||
<result property="createTime" column="create_time"/>
|
||||
<result property="updateTime" column="update_time"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectWmsBatchJobVo">
|
||||
select id, job_no, source_type, biz_type, file_name, file_path, status, progress,
|
||||
total_count, success_count, fail_count, error_msg, create_by, create_time, update_time
|
||||
from wms_batch_job
|
||||
</sql>
|
||||
|
||||
<select id="selectWmsBatchJobList" parameterType="WmsBatchJob" resultMap="WmsBatchJobResult">
|
||||
<include refid="selectWmsBatchJobVo"/>
|
||||
<where>
|
||||
<if test="jobNo != null and jobNo != ''">and job_no like concat('%', #{jobNo}, '%')</if>
|
||||
<if test="sourceType != null and sourceType != ''">and source_type = #{sourceType}</if>
|
||||
<if test="bizType != null and bizType != ''">and biz_type = #{bizType}</if>
|
||||
<if test="status != null">and status = #{status}</if>
|
||||
</where>
|
||||
order by id desc
|
||||
</select>
|
||||
|
||||
<select id="selectWmsBatchJobById" parameterType="Long" resultMap="WmsBatchJobResult">
|
||||
<include refid="selectWmsBatchJobVo"/> where id = #{id}
|
||||
</select>
|
||||
|
||||
<insert id="insertWmsBatchJob" parameterType="WmsBatchJob" useGeneratedKeys="true" keyProperty="id">
|
||||
insert into wms_batch_job(job_no, source_type, biz_type, file_name, file_path, status, progress,
|
||||
total_count, success_count, fail_count, error_msg, create_by, create_time)
|
||||
values(#{jobNo}, #{sourceType}, #{bizType}, #{fileName}, #{filePath}, #{status}, #{progress},
|
||||
#{totalCount}, #{successCount}, #{failCount}, #{errorMsg}, #{createBy}, sysdate())
|
||||
</insert>
|
||||
|
||||
<update id="updateWmsBatchJob" parameterType="WmsBatchJob">
|
||||
update wms_batch_job
|
||||
<set>
|
||||
<if test="sourceType != null">source_type = #{sourceType},</if>
|
||||
<if test="bizType != null">biz_type = #{bizType},</if>
|
||||
<if test="fileName != null">file_name = #{fileName},</if>
|
||||
<if test="filePath != null">file_path = #{filePath},</if>
|
||||
<if test="status != null">status = #{status},</if>
|
||||
<if test="progress != null">progress = #{progress},</if>
|
||||
<if test="totalCount != null">total_count = #{totalCount},</if>
|
||||
<if test="successCount != null">success_count = #{successCount},</if>
|
||||
<if test="failCount != null">fail_count = #{failCount},</if>
|
||||
error_msg = #{errorMsg},
|
||||
update_time = sysdate()
|
||||
</set>
|
||||
where id = #{id}
|
||||
</update>
|
||||
</mapper>
|
||||
46
ruoyi-ui/src/api/warehouse/batchImport.js
Normal file
46
ruoyi-ui/src/api/warehouse/batchImport.js
Normal file
@ -0,0 +1,46 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listBatchJob(query) {
|
||||
return request({ url: '/warehouse/batch/list', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function uploadBatchFile(file) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
return request({
|
||||
url: '/warehouse/batch/upload',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
|
||||
timeout: 60000
|
||||
})
|
||||
}
|
||||
|
||||
export function getBatchJob(id) {
|
||||
return request({ url: '/warehouse/batch/' + id, method: 'get' })
|
||||
}
|
||||
|
||||
export function listBatchItems(jobId) {
|
||||
return request({ url: '/warehouse/batch/' + jobId + '/items', method: 'get' })
|
||||
}
|
||||
|
||||
export function updateBatchItem(data) {
|
||||
return request({ url: '/warehouse/batch/item', method: 'put', data })
|
||||
}
|
||||
|
||||
export function updateBatchItemAction(jobId, data) {
|
||||
return request({ url: '/warehouse/batch/' + jobId + '/items/action', method: 'put', data })
|
||||
}
|
||||
|
||||
export function deleteBatchItem(id) {
|
||||
return request({ url: '/warehouse/batch/item/' + id, method: 'delete' })
|
||||
}
|
||||
|
||||
export function confirmBatchJob(jobId) {
|
||||
return request({
|
||||
url: '/warehouse/batch/' + jobId + '/confirm',
|
||||
method: 'post',
|
||||
timeout: 120000,
|
||||
headers: { repeatSubmit: false }
|
||||
})
|
||||
}
|
||||
@ -67,6 +67,19 @@ export const constantRoutes = [
|
||||
hidden: true,
|
||||
meta: { title: '移动库存' }
|
||||
},
|
||||
{
|
||||
path: '/warehouse/batch-import',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/warehouse/batchImport/index'),
|
||||
name: 'WmsBatchImport',
|
||||
meta: { title: '批量导入' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
component: Layout,
|
||||
|
||||
77
ruoyi-ui/src/utils/imageCompress.js
Normal file
77
ruoyi-ui/src/utils/imageCompress.js
Normal file
@ -0,0 +1,77 @@
|
||||
function isImageFile(file) {
|
||||
return file && file.type && file.type.indexOf('image/') === 0
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas, type, quality) {
|
||||
return new Promise(resolve => {
|
||||
if (!canvas.toBlob) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
canvas.toBlob(blob => resolve(blob), type, quality)
|
||||
})
|
||||
}
|
||||
|
||||
function loadImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = event => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = reject
|
||||
image.src = event.target.result
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export async function compressImageForUpload(file, options = {}) {
|
||||
const maxSize = options.maxSize || 3 * 1024 * 1024
|
||||
const maxWidth = options.maxWidth || 1600
|
||||
const maxHeight = options.maxHeight || 1600
|
||||
const outputType = options.outputType || 'image/jpeg'
|
||||
const minQuality = options.minQuality || 0.55
|
||||
|
||||
if (!isImageFile(file) || typeof FileReader === 'undefined' || typeof document === 'undefined') {
|
||||
return file
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await loadImage(file)
|
||||
let scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1)
|
||||
let quality = options.quality || 0.82
|
||||
let bestBlob = null
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const width = Math.max(1, Math.round(image.width * scale))
|
||||
const height = Math.max(1, Math.round(image.height * scale))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
|
||||
const blob = await canvasToBlob(canvas, outputType, quality)
|
||||
if (!blob) {
|
||||
return file
|
||||
}
|
||||
bestBlob = blob
|
||||
if (blob.size <= maxSize) {
|
||||
break
|
||||
}
|
||||
scale = scale * 0.82
|
||||
quality = Math.max(minQuality, quality - 0.08)
|
||||
}
|
||||
|
||||
if (!bestBlob || bestBlob.size >= file.size) {
|
||||
return file
|
||||
}
|
||||
const fileName = file.name ? file.name.replace(/\.[^.]+$/, '.jpg') : 'upload.jpg'
|
||||
return new File([bestBlob], fileName, { type: outputType, lastModified: Date.now() })
|
||||
} catch (error) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,10 @@
|
||||
<h1>库存管理系统</h1>
|
||||
<p>面向库区、产品、入库、出库和盘库的日常库存作业台。</p>
|
||||
</div>
|
||||
<el-button type="primary" icon="el-icon-mobile-phone" @click="$router.push('/mobile')">移动端入口</el-button>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" icon="el-icon-upload2" @click="$router.push('/warehouse/batch-import')">批量导入</el-button>
|
||||
<el-button type="primary" plain icon="el-icon-mobile-phone" @click="$router.push('/mobile')">移动端入口</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="agent-panel">
|
||||
@ -337,6 +340,12 @@ export default {
|
||||
border: 1px solid #e6ebf2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
|
||||
@ -96,6 +96,7 @@
|
||||
<button @click="switchTab('stock')">查库存</button>
|
||||
<button @click="switchTab('product')" v-hasPermi="['warehouse:product:list']">产品维护</button>
|
||||
<button @click="switchTab('area')" v-hasPermi="['warehouse:area:list']">库区维护</button>
|
||||
<button @click="switchTab('batch')">批量导入</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-title">库存概览</div>
|
||||
@ -303,6 +304,108 @@
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'batch'" class="mobile-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="section-title">智能批量导入</div>
|
||||
<div class="section-subtitle">上传 Excel 或图片,解析后确认再写入库存数据</div>
|
||||
</div>
|
||||
<el-button size="mini" icon="el-icon-arrow-left" @click="switchTab('home')">返回</el-button>
|
||||
</div>
|
||||
|
||||
<el-upload
|
||||
class="mobile-upload"
|
||||
action="#"
|
||||
accept=".xls,.xlsx,.jpg,.jpeg,.png,.bmp"
|
||||
:show-file-list="false"
|
||||
:http-request="handleBatchUpload"
|
||||
:disabled="batch.uploading"
|
||||
>
|
||||
<el-button class="full-button" type="primary" icon="el-icon-upload2" :loading="batch.uploading">上传 Excel / 图片</el-button>
|
||||
<div slot="tip" class="upload-tip">图片识别耗时更久,上传后请等待解析进度完成。</div>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="batch.currentJob" class="batch-current">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">{{ batch.currentJob.jobNo }}</div>
|
||||
<div class="card-subtitle">{{ batch.currentJob.fileName }} · {{ batchStatusLabel(batch.currentJob.status) }}</div>
|
||||
</div>
|
||||
<el-tag size="mini" :type="batchStatusType(batch.currentJob.status)">{{ batchSourceLabel(batch.currentJob.sourceType) }}</el-tag>
|
||||
</div>
|
||||
<el-progress :percentage="batch.currentJob.progress || 0" :status="batchProgressStatus(batch.currentJob)" />
|
||||
<div class="batch-stats">
|
||||
<el-tag size="mini">总数 {{ batch.currentJob.totalCount || 0 }}</el-tag>
|
||||
<el-tag size="mini" type="success">可执行 {{ batch.currentJob.successCount || 0 }}</el-tag>
|
||||
<el-tag size="mini" type="danger">异常 {{ batch.currentJob.failCount || 0 }}</el-tag>
|
||||
</div>
|
||||
<el-alert v-if="batch.currentJob.errorMsg" :title="batch.currentJob.errorMsg" type="warning" show-icon :closable="false" />
|
||||
<el-button
|
||||
class="full-button confirm-button"
|
||||
type="danger"
|
||||
icon="el-icon-check"
|
||||
:loading="batch.confirming"
|
||||
:disabled="batch.currentJob.status !== 2 || batch.currentJob.failCount > 0 || !batch.items.length"
|
||||
@click="confirmBatch"
|
||||
>确认执行当前任务</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="batch.items.length" class="batch-action-panel">
|
||||
<el-select v-model="batch.actionType" placeholder="批量设置操作类型" clearable>
|
||||
<el-option label="新增库区" value="create_area" />
|
||||
<el-option label="新增产品" value="create_product" />
|
||||
<el-option label="入库登记" value="create_in_record" />
|
||||
<el-option label="出库" value="create_out_record" />
|
||||
<el-option label="盘库登记" value="create_stock_check" />
|
||||
</el-select>
|
||||
<div class="batch-action-buttons">
|
||||
<el-button type="primary" plain :loading="batch.batchSaving" :disabled="!canEditBatchItems || !batch.actionType" @click="applyMobileBatchAction(false)">应用到全部</el-button>
|
||||
<el-button type="primary" plain :loading="batch.batchSaving" :disabled="!canEditBatchItems || !batch.actionType || !batch.selectedItemIds.length" @click="applyMobileBatchAction(true)">应用已选 {{ batch.selectedItemIds.length }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="batch.itemLoading" class="empty-tip">正在加载明细...</div>
|
||||
<div v-else-if="batch.currentJob && batch.items.length === 0" class="empty-tip">暂无解析明细</div>
|
||||
<article v-for="item in batch.items" :key="'batch-item-' + item.id" class="mobile-card batch-item-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">第 {{ item.rowNo }} 行 · {{ batchActionLabel(item.actionType) }}</div>
|
||||
<div class="card-subtitle">{{ item.areaName || '未识别库区' }}</div>
|
||||
</div>
|
||||
<el-checkbox :value="isBatchItemSelected(item)" :disabled="!canEditBatchItems" @change="toggleBatchItem(item, $event)">选择</el-checkbox>
|
||||
</div>
|
||||
<div class="batch-product">{{ item.productName || '未识别产品' }}</div>
|
||||
<div class="meta-line">{{ batchProductMeta(item) || '无产品明细' }}</div>
|
||||
<div class="record-line">
|
||||
<span v-if="item.quantity !== undefined && item.quantity !== null">数量:{{ item.quantity }}</span>
|
||||
<span v-if="item.operatorName">人员:{{ item.operatorName }}</span>
|
||||
</div>
|
||||
<div class="batch-row-status">
|
||||
<el-tag size="mini" :type="batchItemStatusType(item.status)">{{ batchItemStatusLabel(item.status) }}</el-tag>
|
||||
<span v-if="item.errorMsg" class="error-text">{{ item.errorMsg }}</span>
|
||||
<span v-else-if="item.warningMsg">{{ item.warningMsg }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="panel-title">最近任务</div>
|
||||
<div v-if="batch.jobLoading" class="empty-tip">正在加载任务...</div>
|
||||
<div v-else-if="batch.jobList.length === 0" class="empty-tip">暂无批量任务</div>
|
||||
<article v-for="job in batch.jobList" :key="'batch-job-' + job.id" class="mobile-card batch-job-card" @click="selectBatchJob(job)">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title">{{ job.jobNo }}</div>
|
||||
<div class="card-subtitle">{{ job.fileName || '-' }}</div>
|
||||
</div>
|
||||
<el-tag size="mini" :type="batchStatusType(job.status)">{{ batchStatusLabel(job.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="record-line">
|
||||
<span>{{ batchSourceLabel(job.sourceType) }}</span>
|
||||
<span>进度 {{ job.progress || 0 }}%</span>
|
||||
<span>异常 {{ job.failCount || 0 }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav class="bottom-nav">
|
||||
@ -438,6 +541,8 @@ import { listInRecord, getInRecord, addInRecord, updateInRecord, delInRecord, co
|
||||
import { listOutRecord, addOutRecord } from '@/api/warehouse/outRecord'
|
||||
import { listStockCheck, addStockCheck, confirmStockCheck } from '@/api/warehouse/stockCheck'
|
||||
import { analyzeAgent, executeAgent } from '@/api/warehouse/agent'
|
||||
import { listBatchJob, uploadBatchFile, getBatchJob, listBatchItems, updateBatchItemAction, confirmBatchJob } from '@/api/warehouse/batchImport'
|
||||
import { compressImageForUpload } from '@/utils/imageCompress'
|
||||
|
||||
export default {
|
||||
name: 'MobileWarehouse',
|
||||
@ -473,6 +578,19 @@ export default {
|
||||
contextResults: [],
|
||||
pendingNextPlan: null,
|
||||
plan: null
|
||||
},
|
||||
batch: {
|
||||
uploading: false,
|
||||
confirming: false,
|
||||
jobLoading: false,
|
||||
itemLoading: false,
|
||||
batchSaving: false,
|
||||
actionType: undefined,
|
||||
jobList: [],
|
||||
currentJob: null,
|
||||
items: [],
|
||||
selectedItemIds: [],
|
||||
pollTimer: null
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -487,6 +605,9 @@ export default {
|
||||
unitOptions() {
|
||||
return this.distinctProductOptions('unit')
|
||||
},
|
||||
canEditBatchItems() {
|
||||
return this.batch.currentJob && ![3, 4].includes(this.batch.currentJob.status)
|
||||
},
|
||||
agentPlanItems() {
|
||||
const plan = this.agent.plan
|
||||
if (!plan) {
|
||||
@ -511,6 +632,9 @@ export default {
|
||||
this.loadOptions()
|
||||
this.refreshAll()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.stopBatchPolling()
|
||||
},
|
||||
methods: {
|
||||
analyzeAgentText() {
|
||||
const text = (this.agent.text || '').trim()
|
||||
@ -758,6 +882,156 @@ export default {
|
||||
loadAreas() {
|
||||
listArea(this.areaQuery).then(res => { this.areaList = res.rows || [] })
|
||||
},
|
||||
async handleBatchUpload(option) {
|
||||
this.batch.uploading = true
|
||||
const uploadFile = await compressImageForUpload(option.file)
|
||||
if (uploadFile !== option.file) {
|
||||
this.$modal.msgSuccess('照片已压缩,正在上传解析')
|
||||
}
|
||||
uploadBatchFile(uploadFile).then(res => {
|
||||
this.batch.currentJob = res.data
|
||||
this.batch.items = []
|
||||
this.batch.selectedItemIds = []
|
||||
this.$modal.msgSuccess('上传成功,正在后台解析')
|
||||
this.startBatchPolling(res.data.id)
|
||||
this.loadBatchJobs()
|
||||
}).finally(() => {
|
||||
this.batch.uploading = false
|
||||
})
|
||||
},
|
||||
loadBatchJobs() {
|
||||
this.batch.jobLoading = true
|
||||
listBatchJob({ pageNum: 1, pageSize: 10 }).then(res => {
|
||||
this.batch.jobList = res.rows || []
|
||||
}).finally(() => {
|
||||
this.batch.jobLoading = false
|
||||
})
|
||||
},
|
||||
selectBatchJob(row) {
|
||||
this.batch.currentJob = row
|
||||
this.loadBatchItems(row.id)
|
||||
if ([0, 1, 3].includes(row.status)) {
|
||||
this.startBatchPolling(row.id)
|
||||
}
|
||||
},
|
||||
startBatchPolling(jobId) {
|
||||
this.stopBatchPolling()
|
||||
this.batch.pollTimer = setInterval(() => {
|
||||
this.refreshBatchJob(jobId)
|
||||
}, 1500)
|
||||
this.refreshBatchJob(jobId)
|
||||
},
|
||||
stopBatchPolling() {
|
||||
if (this.batch.pollTimer) {
|
||||
clearInterval(this.batch.pollTimer)
|
||||
this.batch.pollTimer = null
|
||||
}
|
||||
},
|
||||
refreshBatchJob(jobId) {
|
||||
getBatchJob(jobId).then(res => {
|
||||
this.batch.currentJob = res.data
|
||||
if (this.batch.currentJob && [2, 4, 5, 6].includes(this.batch.currentJob.status)) {
|
||||
this.stopBatchPolling()
|
||||
this.loadBatchItems(jobId)
|
||||
this.loadBatchJobs()
|
||||
}
|
||||
})
|
||||
},
|
||||
loadBatchItems(jobId) {
|
||||
this.batch.itemLoading = true
|
||||
listBatchItems(jobId).then(res => {
|
||||
this.batch.items = res.data || []
|
||||
this.batch.selectedItemIds = []
|
||||
}).finally(() => {
|
||||
this.batch.itemLoading = false
|
||||
})
|
||||
},
|
||||
isBatchItemSelected(item) {
|
||||
return this.batch.selectedItemIds.includes(item.id)
|
||||
},
|
||||
toggleBatchItem(item, checked) {
|
||||
const ids = this.batch.selectedItemIds.slice()
|
||||
const index = ids.indexOf(item.id)
|
||||
if (checked && index === -1) {
|
||||
ids.push(item.id)
|
||||
}
|
||||
if (!checked && index !== -1) {
|
||||
ids.splice(index, 1)
|
||||
}
|
||||
this.batch.selectedItemIds = ids
|
||||
},
|
||||
applyMobileBatchAction(onlySelected) {
|
||||
if (!this.batch.currentJob || !this.batch.actionType) {
|
||||
return
|
||||
}
|
||||
const itemIds = onlySelected ? this.batch.selectedItemIds : undefined
|
||||
if (onlySelected && !itemIds.length) {
|
||||
return
|
||||
}
|
||||
const label = this.batchActionLabel(this.batch.actionType)
|
||||
this.$modal.confirm('确认将' + (onlySelected ? '已选' : '全部') + '明细的操作类型设置为“' + label + '”?').then(() => {
|
||||
this.batch.batchSaving = true
|
||||
return updateBatchItemAction(this.batch.currentJob.id, { actionType: this.batch.actionType, itemIds })
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('批量设置完成')
|
||||
this.loadBatchItems(this.batch.currentJob.id)
|
||||
this.refreshBatchJob(this.batch.currentJob.id)
|
||||
}).finally(() => {
|
||||
this.batch.batchSaving = false
|
||||
})
|
||||
},
|
||||
confirmBatch() {
|
||||
if (!this.batch.currentJob) {
|
||||
return
|
||||
}
|
||||
this.$modal.confirm('确认执行当前批量任务?执行后会真正写入库存业务数据。').then(() => {
|
||||
this.batch.confirming = true
|
||||
return confirmBatchJob(this.batch.currentJob.id)
|
||||
}).then(res => {
|
||||
this.batch.currentJob = res.data
|
||||
this.$modal.msgSuccess('执行完成')
|
||||
this.loadBatchItems(this.batch.currentJob.id)
|
||||
this.loadBatchJobs()
|
||||
this.refreshAll()
|
||||
this.loadOptions()
|
||||
}).finally(() => {
|
||||
this.batch.confirming = false
|
||||
})
|
||||
},
|
||||
batchStatusLabel(status) {
|
||||
return { 0: '已上传', 1: '解析中', 2: '待确认', 3: '执行中', 4: '完成', 5: '失败', 6: '已取消' }[status] || '-'
|
||||
},
|
||||
batchStatusType(status) {
|
||||
return { 2: 'warning', 4: 'success', 5: 'danger', 6: 'info' }[status] || 'info'
|
||||
},
|
||||
batchSourceLabel(sourceType) {
|
||||
return { excel: 'Excel', image: '图片', file: '文件' }[sourceType] || sourceType || '-'
|
||||
},
|
||||
batchItemStatusLabel(status) {
|
||||
return { 1: '可执行', 2: '异常', 3: '已执行', 4: '失败' }[status] || '-'
|
||||
},
|
||||
batchItemStatusType(status) {
|
||||
return { 1: 'success', 2: 'danger', 3: 'success', 4: 'danger' }[status] || 'info'
|
||||
},
|
||||
batchProgressStatus(job) {
|
||||
if (!job) return undefined
|
||||
if (job.status === 4) return 'success'
|
||||
if (job.status === 5) return 'exception'
|
||||
return undefined
|
||||
},
|
||||
batchActionLabel(action) {
|
||||
return {
|
||||
create_area: '新增库区',
|
||||
create_product: '新增产品',
|
||||
create_in_record: '入库登记',
|
||||
create_out_record: '出库',
|
||||
create_stock_check: '盘库登记',
|
||||
unknown: '未识别'
|
||||
}[action] || action || '-'
|
||||
},
|
||||
batchProductMeta(row) {
|
||||
return [row.brand, row.category, row.spec, row.unit].filter(Boolean).join(' / ')
|
||||
},
|
||||
switchTab(tab) {
|
||||
this.activeTab = tab
|
||||
if (tab === 'stock') this.loadStock()
|
||||
@ -766,6 +1040,7 @@ export default {
|
||||
if (tab === 'check') this.loadCheckRecords()
|
||||
if (tab === 'product') this.loadProducts()
|
||||
if (tab === 'area') this.loadAreas()
|
||||
if (tab === 'batch') this.loadBatchJobs()
|
||||
if (tab === 'home') this.refreshAll()
|
||||
},
|
||||
areaLabel(area) {
|
||||
@ -1249,6 +1524,70 @@ export default {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mobile-upload {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mobile-upload ::v-deep .el-upload {
|
||||
width: 100%;
|
||||
}
|
||||
.upload-tip {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.batch-current,
|
||||
.batch-action-panel {
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e8edf3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.batch-current .el-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.batch-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.confirm-button {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.batch-action-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.batch-action-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.batch-action-buttons .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
.batch-product {
|
||||
margin-top: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 22px;
|
||||
}
|
||||
.batch-row-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
color: #7a8794;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.batch-job-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
.error-text {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.mobile-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e8edf3;
|
||||
|
||||
415
ruoyi-ui/src/views/warehouse/batchImport/index.vue
Normal file
415
ruoyi-ui/src/views/warehouse/batchImport/index.vue
Normal file
@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="app-container batch-page">
|
||||
<el-card shadow="never" class="batch-card">
|
||||
<div slot="header" class="card-head">
|
||||
<div>
|
||||
<span class="title">智能批量导入</span>
|
||||
<span class="subtitle">支持 Excel 和图片,解析后先确认明细,再写入库存业务表。</span>
|
||||
</div>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="loadJobs">刷新任务</el-button>
|
||||
</div>
|
||||
|
||||
<el-upload
|
||||
class="upload-box"
|
||||
drag
|
||||
action="#"
|
||||
accept=".xls,.xlsx,.jpg,.jpeg,.png,.bmp"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<i class="el-icon-upload"></i>
|
||||
<div class="el-upload__text">拖入 Excel 或图片,或 <em>点击上传</em></div>
|
||||
<div slot="tip" class="el-upload__tip">Excel 支持 xls/xlsx;图片支持 jpg/png/bmp。图片会调用大模型识别,耗时可能更久。</div>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="currentJob" class="job-panel">
|
||||
<div class="job-main">
|
||||
<div>
|
||||
<div class="job-no">{{ currentJob.jobNo }}</div>
|
||||
<div class="job-meta">{{ currentJob.fileName }} | {{ statusLabel(currentJob.status) }}</div>
|
||||
</div>
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="el-icon-check"
|
||||
:loading="confirming"
|
||||
:disabled="currentJob.status !== 2 || currentJob.failCount > 0 || !items.length"
|
||||
@click="handleConfirm"
|
||||
>确认执行</el-button>
|
||||
</div>
|
||||
<el-progress :percentage="currentJob.progress || 0" :status="progressStatus(currentJob)" />
|
||||
<div class="job-stats">
|
||||
<el-tag size="small">总数 {{ currentJob.totalCount || 0 }}</el-tag>
|
||||
<el-tag size="small" type="success">可执行 {{ currentJob.successCount || 0 }}</el-tag>
|
||||
<el-tag size="small" type="danger">异常 {{ currentJob.failCount || 0 }}</el-tag>
|
||||
<el-alert v-if="currentJob.errorMsg" :title="currentJob.errorMsg" type="warning" show-icon :closable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :lg="7">
|
||||
<el-card shadow="never" class="batch-card">
|
||||
<div slot="header" class="card-head">
|
||||
<span class="title">最近任务</span>
|
||||
</div>
|
||||
<el-table v-loading="jobLoading" :data="jobList" size="mini" height="420" @row-click="selectJob">
|
||||
<el-table-column label="任务号" prop="jobNo" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="来源" width="72">
|
||||
<template slot-scope="scope">{{ sourceLabel(scope.row.sourceType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="86">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" prop="progress" width="70" align="right" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :lg="17">
|
||||
<el-card shadow="never" class="batch-card">
|
||||
<div slot="header" class="card-head">
|
||||
<span class="title">待确认明细</span>
|
||||
<span class="subtitle">异常明细需要修改或删除后才能确认执行。</span>
|
||||
</div>
|
||||
<div class="batch-actions">
|
||||
<el-select v-model="batchActionType" size="small" placeholder="批量设置操作类型" clearable>
|
||||
<el-option label="新增库区" value="create_area" />
|
||||
<el-option label="新增产品" value="create_product" />
|
||||
<el-option label="入库登记" value="create_in_record" />
|
||||
<el-option label="出库" value="create_out_record" />
|
||||
<el-option label="盘库登记" value="create_stock_check" />
|
||||
</el-select>
|
||||
<el-button size="small" type="primary" plain :loading="batchSaving" :disabled="!canEditItems || !batchActionType || !items.length" @click="applyBatchAction(false)">应用到全部</el-button>
|
||||
<el-button size="small" type="primary" plain :loading="batchSaving" :disabled="!canEditItems || !batchActionType || !selectedItems.length" @click="applyBatchAction(true)">应用到已选 {{ selectedItems.length }}</el-button>
|
||||
</div>
|
||||
<el-table v-loading="itemLoading" :data="items" size="mini" height="520" @selection-change="handleItemSelectionChange">
|
||||
<el-table-column type="selection" width="45" />
|
||||
<el-table-column label="行" prop="rowNo" width="58" />
|
||||
<el-table-column label="操作" width="110">
|
||||
<template slot-scope="scope">{{ actionLabel(scope.row.actionType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="92">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="itemStatusType(scope.row.status)" size="mini">{{ itemStatusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库区" prop="areaName" width="110" show-overflow-tooltip />
|
||||
<el-table-column label="产品" min-width="190" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<div>{{ scope.row.productName || '-' }}</div>
|
||||
<div class="meta">{{ productMeta(scope.row) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" prop="quantity" width="90" align="right" />
|
||||
<el-table-column label="人员" prop="operatorName" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="提示/错误" min-width="220" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="error-text" v-if="scope.row.errorMsg">{{ scope.row.errorMsg }}</span>
|
||||
<span v-else>{{ scope.row.warningMsg || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="mini" @click="openEdit(scope.row)">修改</el-button>
|
||||
<el-button type="text" size="mini" class="danger-link" @click="removeItem(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog title="修改明细" :visible.sync="editOpen" width="620px" append-to-body>
|
||||
<el-form ref="editForm" :model="editForm" label-width="90px">
|
||||
<el-form-item label="操作类型">
|
||||
<el-select v-model="editForm.actionType" placeholder="请选择操作类型" style="width: 100%">
|
||||
<el-option label="新增库区" value="create_area" />
|
||||
<el-option label="新增产品" value="create_product" />
|
||||
<el-option label="入库登记" value="create_in_record" />
|
||||
<el-option label="出库" value="create_out_record" />
|
||||
<el-option label="盘库登记" value="create_stock_check" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="库区"><el-input v-model="editForm.areaName" /></el-form-item>
|
||||
<el-form-item label="产品名称"><el-input v-model="editForm.productName" /></el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12"><el-form-item label="品牌"><el-input v-model="editForm.brand" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="类别"><el-input v-model="editForm.category" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12"><el-form-item label="规格"><el-input v-model="editForm.spec" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="单位"><el-input v-model="editForm.unit" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12"><el-form-item label="数量"><el-input-number v-model="editForm.quantity" :precision="3" :min="0" style="width: 100%" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="类型编码"><el-input-number v-model="editForm.typeCode" :min="1" :max="4" style="width: 100%" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="操作人"><el-input v-model="editForm.operatorName" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" :loading="savingItem" @click="saveItem">保 存</el-button>
|
||||
<el-button @click="editOpen = false">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listBatchJob, uploadBatchFile, getBatchJob, listBatchItems, updateBatchItem, updateBatchItemAction, deleteBatchItem, confirmBatchJob } from '@/api/warehouse/batchImport'
|
||||
|
||||
export default {
|
||||
name: 'WmsBatchImport',
|
||||
data() {
|
||||
return {
|
||||
uploading: false,
|
||||
confirming: false,
|
||||
jobLoading: false,
|
||||
itemLoading: false,
|
||||
savingItem: false,
|
||||
batchSaving: false,
|
||||
batchActionType: undefined,
|
||||
jobList: [],
|
||||
currentJob: null,
|
||||
items: [],
|
||||
selectedItems: [],
|
||||
pollTimer: null,
|
||||
editOpen: false,
|
||||
editForm: {}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadJobs()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.stopPolling()
|
||||
},
|
||||
methods: {
|
||||
handleUpload(option) {
|
||||
this.uploading = true
|
||||
uploadBatchFile(option.file).then(res => {
|
||||
this.currentJob = res.data
|
||||
this.items = []
|
||||
this.$modal.msgSuccess('上传成功,正在后台解析,请留意进度')
|
||||
this.startPolling(res.data.id)
|
||||
this.loadJobs()
|
||||
}).finally(() => {
|
||||
this.uploading = false
|
||||
})
|
||||
},
|
||||
loadJobs() {
|
||||
this.jobLoading = true
|
||||
listBatchJob({ pageNum: 1, pageSize: 20 }).then(res => {
|
||||
this.jobList = res.rows || []
|
||||
}).finally(() => {
|
||||
this.jobLoading = false
|
||||
})
|
||||
},
|
||||
selectJob(row) {
|
||||
this.currentJob = row
|
||||
this.loadItems(row.id)
|
||||
if ([0, 1, 3].includes(row.status)) {
|
||||
this.startPolling(row.id)
|
||||
}
|
||||
},
|
||||
startPolling(jobId) {
|
||||
this.stopPolling()
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.refreshJob(jobId)
|
||||
}, 1500)
|
||||
this.refreshJob(jobId)
|
||||
},
|
||||
stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
},
|
||||
refreshJob(jobId) {
|
||||
getBatchJob(jobId).then(res => {
|
||||
this.currentJob = res.data
|
||||
if (this.currentJob && [2, 4, 5, 6].includes(this.currentJob.status)) {
|
||||
this.stopPolling()
|
||||
this.loadItems(jobId)
|
||||
this.loadJobs()
|
||||
}
|
||||
})
|
||||
},
|
||||
loadItems(jobId) {
|
||||
this.itemLoading = true
|
||||
listBatchItems(jobId).then(res => {
|
||||
this.items = res.data || []
|
||||
this.selectedItems = []
|
||||
}).finally(() => {
|
||||
this.itemLoading = false
|
||||
})
|
||||
},
|
||||
handleItemSelectionChange(selection) {
|
||||
this.selectedItems = selection
|
||||
},
|
||||
openEdit(row) {
|
||||
this.editForm = Object.assign({}, row)
|
||||
this.editOpen = true
|
||||
},
|
||||
saveItem() {
|
||||
this.savingItem = true
|
||||
updateBatchItem(this.editForm).then(res => {
|
||||
this.$modal.msgSuccess('明细已更新')
|
||||
this.editOpen = false
|
||||
this.loadItems(this.currentJob.id)
|
||||
this.refreshJob(this.currentJob.id)
|
||||
}).finally(() => {
|
||||
this.savingItem = false
|
||||
})
|
||||
},
|
||||
removeItem(row) {
|
||||
this.$modal.confirm('确认删除第 ' + row.rowNo + ' 行明细?').then(() => deleteBatchItem(row.id)).then(() => {
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
this.loadItems(this.currentJob.id)
|
||||
this.refreshJob(this.currentJob.id)
|
||||
})
|
||||
},
|
||||
applyBatchAction(onlySelected) {
|
||||
const rows = onlySelected ? this.selectedItems : this.items
|
||||
if (!rows.length || !this.batchActionType) {
|
||||
return
|
||||
}
|
||||
const label = this.actionLabel(this.batchActionType)
|
||||
this.$modal.confirm('确认将' + (onlySelected ? '已选' : '全部') + '明细的操作类型设置为“' + label + '”?').then(() => {
|
||||
this.batchSaving = true
|
||||
const itemIds = onlySelected ? rows.map(item => item.id) : undefined
|
||||
return updateBatchItemAction(this.currentJob.id, { actionType: this.batchActionType, itemIds })
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('批量设置完成')
|
||||
this.loadItems(this.currentJob.id)
|
||||
this.refreshJob(this.currentJob.id)
|
||||
}).finally(() => {
|
||||
this.batchSaving = false
|
||||
})
|
||||
},
|
||||
handleConfirm() {
|
||||
this.$modal.confirm('确认执行当前批量任务?执行后会真正写入库存业务数据。').then(() => {
|
||||
this.confirming = true
|
||||
return confirmBatchJob(this.currentJob.id)
|
||||
}).then(res => {
|
||||
this.currentJob = res.data
|
||||
this.$modal.msgSuccess('执行完成')
|
||||
this.loadItems(this.currentJob.id)
|
||||
this.loadJobs()
|
||||
}).finally(() => {
|
||||
this.confirming = false
|
||||
})
|
||||
},
|
||||
statusLabel(status) {
|
||||
return { 0: '已上传', 1: '解析中', 2: '待确认', 3: '执行中', 4: '完成', 5: '失败', 6: '已取消' }[status] || '-'
|
||||
},
|
||||
statusType(status) {
|
||||
return { 2: 'warning', 4: 'success', 5: 'danger', 6: 'info' }[status] || 'info'
|
||||
},
|
||||
sourceLabel(sourceType) {
|
||||
return { excel: 'Excel', image: '图片', file: '文件' }[sourceType] || sourceType || '-'
|
||||
},
|
||||
itemStatusLabel(status) {
|
||||
return { 1: '可执行', 2: '异常', 3: '已执行', 4: '失败' }[status] || '-'
|
||||
},
|
||||
itemStatusType(status) {
|
||||
return { 1: 'success', 2: 'danger', 3: 'success', 4: 'danger' }[status] || 'info'
|
||||
},
|
||||
progressStatus(job) {
|
||||
if (!job) return undefined
|
||||
if (job.status === 4) return 'success'
|
||||
if (job.status === 5) return 'exception'
|
||||
return undefined
|
||||
},
|
||||
actionLabel(action) {
|
||||
return {
|
||||
create_area: '新增库区',
|
||||
create_product: '新增产品',
|
||||
create_in_record: '入库登记',
|
||||
create_out_record: '出库',
|
||||
create_stock_check: '盘库登记',
|
||||
unknown: '未识别'
|
||||
}[action] || action || '-'
|
||||
},
|
||||
productMeta(row) {
|
||||
return [row.brand, row.category, row.spec, row.unit].filter(Boolean).join(' / ')
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canEditItems() {
|
||||
return this.currentJob && ![3, 4].includes(this.currentJob.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.batch-page {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
.batch-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
.subtitle {
|
||||
margin-left: 10px;
|
||||
color: #7a8794;
|
||||
font-size: 12px;
|
||||
}
|
||||
.upload-box {
|
||||
max-width: 620px;
|
||||
}
|
||||
.job-panel {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e6ebf2;
|
||||
border-radius: 6px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.job-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.job-no {
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
.job-meta,
|
||||
.meta {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.job-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.error-text,
|
||||
.danger-link {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
49
sql/wms_batch_import.sql
Normal file
49
sql/wms_batch_import.sql
Normal file
@ -0,0 +1,49 @@
|
||||
-- 批量导入任务表。执行一次即可。
|
||||
create table if not exists wms_batch_job (
|
||||
id bigint not null auto_increment comment '主键',
|
||||
job_no varchar(64) not null comment '任务号',
|
||||
source_type varchar(20) not null comment '来源类型 excel/image/file',
|
||||
biz_type varchar(30) default 'mixed' comment '业务类型',
|
||||
file_name varchar(255) default null comment '原始文件名',
|
||||
file_path varchar(500) default null comment '服务器文件路径',
|
||||
status tinyint not null default 0 comment '状态 0上传完成 1解析中 2待确认 3执行中 4完成 5失败 6取消',
|
||||
progress int not null default 0 comment '进度',
|
||||
total_count int not null default 0 comment '总条数',
|
||||
success_count int not null default 0 comment '成功条数',
|
||||
fail_count int not null default 0 comment '失败条数',
|
||||
error_msg varchar(1000) default null comment '错误信息',
|
||||
create_by varchar(64) default null comment '创建人',
|
||||
create_time datetime default null comment '创建时间',
|
||||
update_time datetime default null comment '更新时间',
|
||||
primary key (id),
|
||||
unique key uk_wms_batch_job_no (job_no)
|
||||
) engine=innodb default charset=utf8mb4 comment='WMS批量导入任务';
|
||||
|
||||
create table if not exists wms_batch_item (
|
||||
id bigint not null auto_increment comment '主键',
|
||||
job_id bigint not null comment '任务ID',
|
||||
row_no int default null comment 'Excel行号',
|
||||
action_type varchar(40) not null comment '操作类型',
|
||||
raw_text text comment '原始内容',
|
||||
parsed_json text comment '解析JSON',
|
||||
status tinyint not null default 1 comment '状态 1校验通过 2校验失败 3已执行 4执行失败',
|
||||
error_msg varchar(1000) default null comment '错误信息',
|
||||
warning_msg varchar(1000) default null comment '提示信息',
|
||||
ref_id bigint default null comment '执行后业务ID',
|
||||
area_name varchar(100) default null comment '库区名称',
|
||||
product_name varchar(200) default null comment '产品名称',
|
||||
brand varchar(100) default null comment '品牌',
|
||||
category varchar(100) default null comment '类别',
|
||||
spec varchar(200) default null comment '规格',
|
||||
unit varchar(50) default null comment '单位',
|
||||
quantity decimal(16,3) default null comment '数量',
|
||||
operator_name varchar(100) default null comment '操作人',
|
||||
type_code tinyint default null comment '入库/出库类型',
|
||||
source_no varchar(100) default null comment '来源单据号',
|
||||
remark varchar(500) default null comment '备注',
|
||||
create_time datetime default null comment '创建时间',
|
||||
update_time datetime default null comment '更新时间',
|
||||
primary key (id),
|
||||
key idx_wms_batch_item_job (job_id),
|
||||
key idx_wms_batch_item_status (status)
|
||||
) engine=innodb default charset=utf8mb4 comment='WMS批量导入明细';
|
||||
Loading…
Reference in New Issue
Block a user